diff --git a/Makefile b/Makefile index 8733d9d..500b419 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ generate: $(CC) generate gofmt: - gofmt -w . + gofmt -w pkg/ main.go golint: $(GOPATH)/bin/golint . diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 88353d9..b49885c 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -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 diff --git a/pkg/core/const.go b/pkg/core/const.go index a465adb..cbf6123 100644 --- a/pkg/core/const.go +++ b/pkg/core/const.go @@ -7,4 +7,7 @@ const ( WikiSection = "wiki" IndexURI = WikiSection + "/" + IndexPage + + LoginURI = "/login" + TotpURI = "/totp" ) diff --git a/pkg/core/types.go b/pkg/core/types.go index 0b87320..1e811e1 100644 --- a/pkg/core/types.go +++ b/pkg/core/types.go @@ -26,6 +26,7 @@ type User struct { Username string Password string Created string + Secret string Admin bool Locked int } diff --git a/pkg/db/user.go b/pkg/db/user.go index 5a3c1ba..e76a135 100644 --- a/pkg/db/user.go +++ b/pkg/db/user.go @@ -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 { diff --git a/pkg/otp/otp.go b/pkg/otp/otp.go new file mode 100644 index 0000000..3408eb0 --- /dev/null +++ b/pkg/otp/otp.go @@ -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 +} diff --git a/pkg/server/context.go b/pkg/server/context.go index 5e0be49..55412cf 100644 --- a/pkg/server/context.go +++ b/pkg/server/context.go @@ -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) } diff --git a/pkg/server/handler.go b/pkg/server/handler.go index e11c7f7..c242d1b 100644 --- a/pkg/server/handler.go +++ b/pkg/server/handler.go @@ -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 { diff --git a/pkg/server/routes.go b/pkg/server/routes.go index 9fba797..9ed0603 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -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, diff --git a/pkg/server/server.go b/pkg/server/server.go index c64663e..9ff07ac 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -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) } } diff --git a/pkg/server/templates.go b/pkg/server/templates.go index df5b031..f2a6471 100644 --- a/pkg/server/templates.go +++ b/pkg/server/templates.go @@ -112,6 +112,29 @@ const ( +{{end}}` + loginTotp = `{{define "body"}} + {{end}}` menu = `{{define "menu"}}