added totp support

This commit is contained in:
ston1th 2018-10-27 16:05:17 +02:00
commit bac08dc7cf
16 changed files with 448 additions and 32 deletions

View file

@ -26,7 +26,7 @@ generate:
$(CC) generate
gofmt:
gofmt -w .
gofmt -w pkg/ main.go
golint:
$(GOPATH)/bin/golint .

View file

@ -68,7 +68,7 @@ func initServer(conf core.Config) (err error) {
if err != nil {
return
}
log.Println("gowiki started")
log.Println("gowiki " + conf.Version + " started")
sigs := make(chan os.Signal)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigs

View file

@ -7,4 +7,7 @@ const (
WikiSection = "wiki"
IndexURI = WikiSection + "/" + IndexPage
LoginURI = "/login"
TotpURI = "/totp"
)

View file

@ -26,6 +26,7 @@ type User struct {
Username string
Password string
Created string
Secret string
Admin bool
Locked int
}

View file

@ -6,6 +6,7 @@ import (
"errors"
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/log"
"git.giftfish.de/ston1th/gowiki/pkg/otp"
"git.giftfish.de/ston1th/jwt/v3"
"golang.org/x/crypto/bcrypt"
"regexp"
@ -45,6 +46,13 @@ func (db *DB) GetUserWithoutPassword(username string) (u core.User, err error) {
return
}
func (db *DB) GetUserSecret(username string) (secret string, err error) {
var user core.User
user, err = db.GetUser(username)
secret = user.Secret
return
}
func (db *DB) GetUser(username string) (u core.User, err error) {
err = db.store.Get(userPrefix+username, &u)
return
@ -102,6 +110,34 @@ func (db *DB) Login(username, password string) (u core.User, err error) {
return
}
func (db *DB) Totp(username, pin string) (u core.User, err error) {
u, err = db.GetUser(username)
if err != nil {
return
}
if u.Locked == 3 {
err = errors.New("db: user locked")
return
}
if !otp.Validate(pin, u.Secret) {
u.Locked++
err = db.store.Set(userPrefix+username, u)
if err != nil {
return
}
err = errors.New("db: wrong pin")
return
}
if u.Locked > 0 {
u.Locked = 0
err = db.store.Set(userPrefix+username, u)
if err != nil {
return
}
}
return
}
func (db *DB) UnlockUser(username string) (err error) {
var u core.User
u, err = db.GetUser(username)
@ -118,6 +154,15 @@ func (db *DB) UnlockUser(username string) (err error) {
return
}
func (db *DB) UpdateUserSecret(username, secret string) error {
u, err := db.GetUser(username)
if err != nil {
return err
}
u.Secret = secret
return db.store.Set(userPrefix+username, u)
}
func (db *DB) UpdateUserPassword(username, password string) error {
u, err := db.GetUser(username)
if err != nil {

72
pkg/otp/otp.go Normal file
View file

@ -0,0 +1,72 @@
// Copyright (C) 2018 Marius Schellenberger
package otp
import (
"bytes"
"encoding/base64"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"html/template"
"image/png"
)
const dataUrl = "data:image/png;base64,"
func base64Image(key *otp.Key) (data template.URL, err error) {
var buf bytes.Buffer
img, err := key.Image(200, 200)
if err != nil {
return
}
err = png.Encode(&buf, img)
if err != nil {
return
}
data = template.URL(dataUrl + base64.StdEncoding.EncodeToString(buf.Bytes()))
return
}
type Request struct {
Username string
Image template.URL
Secret string
URL string
}
func New(username string) (req Request, err error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "gowiki",
AccountName: username,
})
if err != nil {
return
}
data, err := base64Image(key)
if err != nil {
return
}
return Request{
Username: username,
Image: data,
Secret: key.Secret(),
URL: key.URL(),
}, nil
}
func Validate(pin, secret string) bool {
if secret == "" {
return true
}
return totp.Validate(pin, secret)
}
func ImageSecretFromURL(url string) (data template.URL, secret string, err error) {
key, err := otp.NewKeyFromURL(url)
if err != nil {
return
}
secret = key.Secret()
data, err = base64Image(key)
return
}

View file

@ -4,6 +4,7 @@ package server
import (
"errors"
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/log"
"git.giftfish.de/ston1th/jwt/v3"
"github.com/gorilla/mux"
@ -19,6 +20,7 @@ const (
sharedClaim = "shared"
sectionClaim = "section"
titleClaim = "title"
totpClaim = "totp"
)
func newContext(w http.ResponseWriter, r *http.Request, s *HTTPServer) (ctx *Context) {
@ -26,29 +28,43 @@ func newContext(w http.ResponseWriter, r *http.Request, s *HTTPServer) (ctx *Con
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-XSS-Protection", "1; mode=block")
h.Set("Content-Security-Policy", "default-src 'none';style-src 'self';frame-ancestors 'none'")
h.Set("Content-Security-Policy", "default-src 'none';style-src 'self';img-src 'self' data:;frame-ancestors 'none'")
ctx = &Context{
Request: r,
Response: w,
Srv: s,
Time: time.Now(),
}
path := ctx.Path()
if path == "/bootstrap.css" || path == "/custom.css" {
return
}
if c, err := r.Cookie(cookieName); err == nil {
t, err := jwt.DecodeToken(c.Value)
if err != nil {
log.Println("DecodeToken:", err)
} else {
if err = s.JWT.Verify(t); err != nil {
log.Println("VerifyToken:", err)
} else {
if !s.DB.LockedOut(t.Claims.GetString(userClaim), t.Claims.GetString(createdClaim)) {
ctx.Token = *t
return
}
if err = s.JWT.Invalidate(t); err != nil {
log.Println("Invalidate:", err)
}
ctx.LogSetCookie("DecodeToken:", err)
return
}
if err = s.JWT.Verify(t); err != nil {
ctx.LogSetCookie("VerifyToken:", err)
return
}
if t.Claims.GetString(totpClaim) != "" {
if path == core.TotpURI || path == core.LoginURI {
ctx.Token = *t
return
}
ctx.Redirect(core.TotpURI, 302)
return nil
}
if !s.DB.LockedOut(t.Claims.GetString(userClaim), t.Claims.GetString(createdClaim)) {
ctx.Token = *t
return
}
if err = s.JWT.Invalidate(t); err != nil {
log.Println("Invalidate:", err)
}
}
ctx.SetCookie(nil)
@ -204,8 +220,16 @@ func (c *Context) LoggedOn() (ok bool) {
return
}
func (c *Context) LogSetCookie(msg string, err error) {
log.Println(msg, err)
c.SetCookie(nil)
}
func (c *Context) SetCookie(claims map[string]interface{}) {
t := jwt.NewToken(claims, nil)
c.SetCookieToken(jwt.NewToken(claims, nil))
}
func (c *Context) SetCookieToken(t *jwt.Token) {
if err := c.Srv.JWT.Sign(t); err != nil {
log.Println(err)
return
@ -233,6 +257,10 @@ func (c *Context) User() string {
return c.Token.Claims.GetString(userClaim)
}
func (c *Context) Totp() string {
return c.Token.Claims.GetString(totpClaim)
}
func (c *Context) Admin() bool {
return c.Token.Claims.GetBool(adminClaim)
}

View file

@ -5,6 +5,7 @@ package server
import (
"git.giftfish.de/ston1th/gowiki/pkg/core"
"git.giftfish.de/ston1th/gowiki/pkg/log"
"git.giftfish.de/ston1th/gowiki/pkg/otp"
"git.giftfish.de/ston1th/gowiki/pkg/render"
"git.giftfish.de/ston1th/jwt/v3"
"net/http"
@ -19,6 +20,16 @@ func (nf *notFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
newContext(w, r, nf.s).NotFound()
}
func totpAuthHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if ctx.Totp() != "" {
h(ctx)
return
}
ctx.Redirect(core.IndexURI, 302)
}
}
func adminAuthHandler(h ctxHandler) ctxHandler {
return func(ctx *Context) {
if ctx.Admin() {
@ -100,6 +111,46 @@ func loginHandler(ctx *Context) {
ctx.Error(err)
return
}
if u.Secret == "" {
ctx.SetCookie(jwt.Claims{userClaim: u.Username, createdClaim: u.Created, adminClaim: u.Admin})
ctx.Redirect(core.IndexURI, 302)
return
}
ctx.SetCookieToken(jwt.NewToken(map[string]interface{}{
totpClaim: u.Username,
jwt.ExpClaim: jwt.NewExp(time.Minute),
}, nil))
ctx.Redirect(core.TotpURI, 302)
}
}
func loginTotpHandler(ctx *Context) {
if ctx.LoggedOn() {
ctx.Redirect(core.IndexURI, 302)
return
}
ctx.Template("loginTotpHandler")
ctx.Data = webData{
Title: "TOTP Verification",
BodyTitle: "TOTP Veriftcation",
}
switch ctx.Method() {
case "GET":
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
pin := ctx.Form("pin")
if pin == "" {
ctx.Error("wrong inputs")
return
}
u, err := ctx.Srv.DB.Totp(ctx.Totp(), pin)
if err != nil {
ctx.Error(err)
return
}
ctx.SetCookie(jwt.Claims{userClaim: u.Username, createdClaim: u.Created, adminClaim: u.Admin})
ctx.Redirect(core.IndexURI, 302)
}
@ -483,6 +534,70 @@ func userEditHandler(ctx *Context) {
}
}
func userTotpHandler(ctx *Context) {
ctx.Template("userTotpHandler")
ctx.Data = webData{
Title: "TOTP",
BodyTitle: "TOTP",
}
req := otp.Request{Username: ctx.Var("user")}
u, err := ctx.Srv.DB.GetUser(req.Username)
if err != nil {
ctx.Error(err)
return
}
if u.Secret == "" {
switch ctx.Method() {
case "GET":
req, err = otp.New(req.Username)
if err != nil {
ctx.Error(err)
return
}
case "POST":
req.URL = ctx.Form("url")
req.Image, req.Secret, err = otp.ImageSecretFromURL(req.URL)
if err != nil {
ctx.Error("wrong inputs")
return
}
}
}
switch ctx.Method() {
case "GET":
ctx.Data.Data = req
ctx.Exec()
case "POST":
if !ctx.CheckXsrf() {
return
}
pin := ctx.Form("pin")
if pin == "" {
ctx.Error("empty pin")
return
}
ctx.Data.Data = req
secret := req.Secret
if u.Secret != "" {
secret = u.Secret
}
valid := otp.Validate(pin, secret)
if ctx.User() != req.Username && ctx.Admin() {
valid = true
}
if !valid {
ctx.Error("validation failed")
return
}
if err := ctx.Srv.DB.UpdateUserSecret(req.Username, req.Secret); err != nil {
ctx.Error(err)
return
}
ctx.Redirect("/user/edit/"+req.Username, 302)
}
}
func userUnlockHandler(ctx *Context) {
user := ctx.Var("user")
if err := ctx.Srv.DB.UnlockUser(user); err != nil {

View file

@ -24,11 +24,6 @@ var routes = []route{
staticHandler,
[]string{"GET"},
},
{
"/login",
loginHandler,
[]string{"GET", "POST"},
},
{
"/search",
searchHandler,
@ -45,6 +40,17 @@ var routes = []route{
allHandler,
[]string{"GET"},
},
{
"/login",
loginHandler,
[]string{"GET", "POST"},
},
{
"/totp",
totpAuthHandler(
loginTotpHandler),
[]string{"GET", "POST"},
},
{
"/blacklist",
authHandler(
@ -81,6 +87,12 @@ var routes = []route{
userEditHandler),
[]string{"GET", "POST"},
},
{
"/user/totp/{user:[a-zA-Z0-9]+$}",
userAuthHandler(
userTotpHandler),
[]string{"GET", "POST"},
},
{
"/logout",
logoutHandler,

View file

@ -94,6 +94,10 @@ func (s *HTTPServer) buildRoutes() http.Handler {
}
func (s *HTTPServer) contextWrapper(h ctxHandler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h(newContext(w, r, s))
ctx := newContext(w, r, s)
if ctx == nil {
return
}
h(ctx)
}
}

View file

@ -112,6 +112,29 @@ const (
</div>
</div>
</div>
{{end}}`
loginTotp = `{{define "body"}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/totp" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="pin">PIN</label>
<input class="form-control input-sm" type="text" id="pin" name="pin" autocomplete="off" autofocus required>
</div>
<button class="btn btn-sm btn-primary" type="submit">Verify</button>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}`
menu = `{{define "menu"}}
<ul class="navbar-nav">
@ -458,6 +481,11 @@ const (
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
{{if .Data.Secret}}
<a href="/user/totp/{{.Data.Username}}" class="btn btn-sm btn-danger">Disable TOTP</a>
{{else}}
<a href="/user/totp/{{.Data.Username}}" class="btn btn-sm btn-success">Enable TOTP</a>
{{end}}
</div>
<div class="card-body">
<form class="form-horizontal" action="/user/edit/{{.Data.Username}}" method="post">
@ -496,8 +524,8 @@ const (
<form class="form-horizontal" action="/user/unlock/{{.Data.Username}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="unlock">Admin Privileges</label>
<button class="btn btn-sm btn-primary" type="submit" id="unlock">Unlock</button>
<label class="col-form-label" for="unlock">Locked</label>
<button class="btn btn-sm btn-warning" type="submit" id="unlock">Unlock</button>
</div>
</form>
{{end}}
@ -536,7 +564,7 @@ const (
{{$admin := .Admin}}
{{range $item := .Data}}
<tr>
<td>{{$item.Username}}</td>
<td>{{$item.Username}} {{if $item.Secret}}<span class="badge badge-success">TOTP</span>{{end}}</td>
{{if $item.Admin}}<td>yes</td>{{else}}<td>no</td>{{end}}
<td>
<div class="btn-group">
@ -590,6 +618,43 @@ const (
</div>
</div>
</div>
{{end}}`
userTotp = `{{define "body"}}
{{if .Data}}
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/user/totp/{{.Data.Username}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
{{if .Data.Secret}}
<input type="hidden" name="url" value="{{.Data.URL}}">
<div class="form-group">
<img src="{{.Data.Image}}" alt="{{.Data.Secret}}">
</div>
<div class="form-group">
<label class="col-form-label" for="secret">Secret</label>
<p id="secret">{{.Data.Secret}}</p>
</div>
{{end}}
<div class="form-group">
<label class="col-form-label" for="pin">PIN</label>
<input class="form-control input-sm" type="text" id="pin" name="pin" autocomplete="off" autofocus required>
</div>
{{if .Data.Secret}}
<button class="btn btn-sm btn-success" type="submit">Enable</button>
{{else}}
<button class="btn btn-sm btn-danger" type="submit">Disable</button>
{{end}}
<a href="/user/edit/{{.Data.Username}}" class="btn btn-sm btn-primary">Back</a>
</form>
</div>
</div>
</div>
{{end}}
{{end}}`
bootstrapCSS = `/*!
* Bootswatch v4.1.3
@ -769,9 +834,10 @@ func (s *HTTPServer) loadTemplates() {
s.res["bootstrap.css"] = []byte(bootstrapCSS)
s.res["custom.css"] = []byte(customCSS)
// pages.go
// pages
s.templ["notFoundHandler"] = parse(index, menu, notFound)
s.templ["loginHandler"] = parse(index, menu, login)
s.templ["loginTotpHandler"] = parse(index, menu, loginTotp)
s.templ["searchHandler"] = parse(index, menu, search)
s.templ["allHandler"] = parse(index, menu, all)
s.templ["pageHandler"] = parse(index, menu, page)
@ -782,11 +848,13 @@ func (s *HTTPServer) loadTemplates() {
s.templ["pageShareHandler"] = parse(index, menu, pageShare)
s.templ["pageBlacklistHandler"] = parse(index, menu, pageBlacklist)
s.templ["pageDelHandler"] = parse(index, menu, pageDel)
// users.go
// users
s.templ["userHandler"] = parse(index, menu, user)
s.templ["userNewHandler"] = parse(index, menu, userNew)
s.templ["userEditHandler"] = parse(index, menu, userEdit)
s.templ["userDelHandler"] = parse(index, menu, userDel)
// totp
s.templ["userTotpHandler"] = parse(index, menu, userTotp)
if errf {
log.Fatal("parse: ", errors.New("parsing templates failed"))

View file

@ -68,9 +68,10 @@ func (s *HTTPServer) loadTemplates() {
s.res["bootstrap.css"] = []byte(bootstrapCSS)
s.res["custom.css"] = []byte(customCSS)
// pages.go
// pages
s.templ["notFoundHandler"] = parse(index, menu, notFound)
s.templ["loginHandler"] = parse(index, menu, login)
s.templ["loginTotpHandler"] = parse(index, menu, loginTotp)
s.templ["searchHandler"] = parse(index, menu, search)
s.templ["allHandler"] = parse(index, menu, all)
s.templ["pageHandler"] = parse(index, menu, page)
@ -81,11 +82,13 @@ func (s *HTTPServer) loadTemplates() {
s.templ["pageShareHandler"] = parse(index, menu, pageShare)
s.templ["pageBlacklistHandler"] = parse(index, menu, pageBlacklist)
s.templ["pageDelHandler"] = parse(index, menu, pageDel)
// users.go
// users
s.templ["userHandler"] = parse(index, menu, user)
s.templ["userNewHandler"] = parse(index, menu, userNew)
s.templ["userEditHandler"] = parse(index, menu, userEdit)
s.templ["userDelHandler"] = parse(index, menu, userDel)
// totp
s.templ["userTotpHandler"] = parse(index, menu, userTotp)
if errf {
log.Fatal("parse: ", errors.New("parsing templates failed"))

23
templates/loginTotp.html Normal file
View file

@ -0,0 +1,23 @@
{{define "body"}}
<div class="page-header">
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/totp" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="pin">PIN</label>
<input class="form-control input-sm" type="text" id="pin" name="pin" autocomplete="off" autofocus required>
</div>
<button class="btn btn-sm btn-primary" type="submit">Verify</button>
</form>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -27,7 +27,7 @@
{{$admin := .Admin}}
{{range $item := .Data}}
<tr>
<td>{{$item.Username}}</td>
<td>{{$item.Username}} {{if $item.Secret}}<span class="badge badge-success">TOTP</span>{{end}}</td>
{{if $item.Admin}}<td>yes</td>{{else}}<td>no</td>{{end}}
<td>
<div class="btn-group">

View file

@ -5,6 +5,11 @@
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
{{if .Data.Secret}}
<a href="/user/totp/{{.Data.Username}}" class="btn btn-sm btn-danger">Disable TOTP</a>
{{else}}
<a href="/user/totp/{{.Data.Username}}" class="btn btn-sm btn-success">Enable TOTP</a>
{{end}}
</div>
<div class="card-body">
<form class="form-horizontal" action="/user/edit/{{.Data.Username}}" method="post">
@ -43,8 +48,8 @@
<form class="form-horizontal" action="/user/unlock/{{.Data.Username}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
<div class="form-group">
<label class="col-form-label" for="unlock">Admin Privileges</label>
<button class="btn btn-sm btn-primary" type="submit" id="unlock">Unlock</button>
<label class="col-form-label" for="unlock">Locked</label>
<button class="btn btn-sm btn-warning" type="submit" id="unlock">Unlock</button>
</div>
</form>
{{end}}

37
templates/userTotp.html Normal file
View file

@ -0,0 +1,37 @@
{{define "body"}}
{{if .Data}}
<div class="row">
<div class="col-xs-4 offset-4">
<div class="card border-primary mx-auto">
<div class="card-header">
<strong>{{.BodyTitle}}</strong>
</div>
<div class="card-body">
<form class="form-horizontal" action="/user/totp/{{.Data.Username}}" method="post">
<input type="hidden" name="token" value="{{.Token}}">
{{if .Data.Secret}}
<input type="hidden" name="url" value="{{.Data.URL}}">
<div class="form-group">
<img src="{{.Data.Image}}" alt="{{.Data.Secret}}">
</div>
<div class="form-group">
<label class="col-form-label" for="secret">Secret</label>
<p id="secret">{{.Data.Secret}}</p>
</div>
{{end}}
<div class="form-group">
<label class="col-form-label" for="pin">PIN</label>
<input class="form-control input-sm" type="text" id="pin" name="pin" autocomplete="off" autofocus required>
</div>
{{if .Data.Secret}}
<button class="btn btn-sm btn-success" type="submit">Enable</button>
{{else}}
<button class="btn btn-sm btn-danger" type="submit">Disable</button>
{{end}}
<a href="/user/edit/{{.Data.Username}}" class="btn btn-sm btn-primary">Back</a>
</form>
</div>
</div>
</div>
{{end}}
{{end}}