From 56cd737203c96eb369c310c458ed4d1faf334d2e Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Thu, 25 Mar 2021 22:33:00 -0500 Subject: [PATCH] Add initial auth token / login stuff --- go.mod | 2 +- go.sum | 5 +- src/auth/auth.go | 128 +++++++++++++++++++++++--- src/auth/token.go | 49 ++++++++++ src/config/types.go | 10 +- src/db/db.go | 3 - src/templates/src/include/header.html | 41 ++++----- src/templates/templates.go | 4 - src/templates/types.go | 11 ++- src/website/errors.go | 24 +++++ src/website/requesthandling.go | 78 ++++++++++++++++ src/website/routes.go | 41 +++++---- 12 files changed, 326 insertions(+), 70 deletions(-) create mode 100644 src/auth/token.go create mode 100644 src/website/errors.go diff --git a/go.mod b/go.mod index 51d6f62..14bda8f 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/stretchr/testify v1.7.0 github.com/teacat/noire v1.1.0 github.com/wellington/go-libsass v0.9.2 - golang.org/x/crypto v0.0.0-20210317152858-513c2a44f670 + golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 ) replace github.com/rs/zerolog v1.20.0 => github.com/bvisness/zerolog v1.20.1-0.20210321191248-05f63bf0e9e0 diff --git a/go.sum b/go.sum index f538b80..ab53262 100644 --- a/go.sum +++ b/go.sum @@ -305,8 +305,8 @@ golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210317152858-513c2a44f670 h1:gzMM0EjIYiRmJI3+jBdFuoynZlpxa2JQZsolKu09BXo= -golang.org/x/crypto v0.0.0-20210317152858-513c2a44f670/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -375,6 +375,7 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/src/auth/auth.go b/src/auth/auth.go index 0d47fe0..5a11ade 100644 --- a/src/auth/auth.go +++ b/src/auth/auth.go @@ -2,61 +2,132 @@ package auth import ( "bytes" + "crypto/rand" "crypto/sha256" "encoding/base64" + "fmt" + "io" "strconv" "strings" "git.handmade.network/hmn/hmn/src/oops" + "golang.org/x/crypto/argon2" "golang.org/x/crypto/pbkdf2" ) type HashAlgorithm string const ( - PBKDF2_SHA256 = "pbkdf2_sha256" + Django_PBKDF2SHA256 HashAlgorithm = "pbkdf2_sha256" + Argon2id HashAlgorithm = "argon2id" ) -const PKBDF2KeyLength = 64 +const saltLength = 16 +const keyLength = 64 type HashedPassword struct { Algorithm HashAlgorithm - Iterations int - Salt string - Hash string + AlgoConfig string // arbitrary info describing the hash parameters (e.g. work factor) + + // To make it easier to handle varying implementations and encodings, + // these fields will always store a form of the data that can be directly + // stored in the database (usually base64-encoded or whatever). + Salt string + Hash string } -func ParseDjangoPasswordString(s string) (HashedPassword, error) { +func ParsePasswordString(s string) (HashedPassword, error) { pieces := strings.SplitN(s, "$", 4) if len(pieces) < 4 { return HashedPassword{}, oops.New(nil, "unrecognized password string format") } - iterations, err := strconv.Atoi(pieces[1]) - if err != nil { - return HashedPassword{}, oops.New(err, "could not parse password iterations") - } - return HashedPassword{ Algorithm: HashAlgorithm(pieces[0]), - Iterations: iterations, + AlgoConfig: pieces[1], Salt: pieces[2], Hash: pieces[3], }, nil } +func (p HashedPassword) String() string { + return fmt.Sprintf("%s$%s$%s$%s", p.Algorithm, p.AlgoConfig, p.Salt, p.Hash) +} + +type Argon2idConfig struct { + Time uint32 + Memory uint32 + Threads uint8 + KeyLength uint32 +} + +func ParseArgon2idConfig(cfg string) (Argon2idConfig, error) { + parts := strings.Split(cfg, ",") + + t64, err := strconv.ParseUint(parts[0][2:], 10, 32) + if err != nil { + return Argon2idConfig{}, oops.New(err, "failed to parse time in Argon2id config") + } + + m64, err := strconv.ParseUint(parts[1][2:], 10, 32) + if err != nil { + return Argon2idConfig{}, oops.New(err, "failed to parse memory in Argon2id config") + } + + p64, err := strconv.ParseUint(parts[2][2:], 10, 8) + if err != nil { + return Argon2idConfig{}, oops.New(err, "failed to parse threads in Argon2id config") + } + + l64, err := strconv.ParseUint(parts[3][2:], 10, 32) + if err != nil { + return Argon2idConfig{}, oops.New(err, "failed to parse key length in Argon2id config") + } + + return Argon2idConfig{ + Time: uint32(t64), + Memory: uint32(m64), + Threads: uint8(p64), + KeyLength: uint32(l64), + }, nil +} + +func (c Argon2idConfig) String() string { + return fmt.Sprintf("t=%v,m=%v,p=%v,l=%v", c.Time, c.Memory, c.Threads, c.KeyLength) +} + func CheckPassword(password string, hashedPassword HashedPassword) (bool, error) { switch hashedPassword.Algorithm { - case PBKDF2_SHA256: + case Argon2id: + cfg, err := ParseArgon2idConfig(hashedPassword.AlgoConfig) + if err != nil { + return false, err + } + + salt, err := base64.StdEncoding.DecodeString(hashedPassword.Salt) + if err != nil { + return false, oops.New(err, "failed to decode salt") + } + + newHash := argon2.IDKey([]byte(password), []byte(salt), cfg.Time, cfg.Memory, cfg.Threads, cfg.KeyLength) + newHashEnc := base64.StdEncoding.EncodeToString(newHash) + + return bytes.Equal([]byte(newHashEnc), []byte(hashedPassword.Hash)), nil + case Django_PBKDF2SHA256: decoded, err := base64.StdEncoding.DecodeString(hashedPassword.Hash) if err != nil { return false, oops.New(nil, "failed to get key length of hashed password") } + iterations, err := strconv.Atoi(hashedPassword.AlgoConfig) + if err != nil { + return false, oops.New(nil, "failed to get PBKDF2 iterations") + } + newHash := pbkdf2.Key( []byte(password), []byte(hashedPassword.Salt), - hashedPassword.Iterations, + iterations, len(decoded), sha256.New, ) @@ -67,3 +138,32 @@ func CheckPassword(password string, hashedPassword HashedPassword) (bool, error) return false, oops.New(nil, "unrecognized password hash algorithm: %s", hashedPassword.Algorithm) } } + +func HashPassword(password string) (HashedPassword, error) { + // Follows the OWASP recommendations as of March 2021. + // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html + + salt := make([]byte, saltLength) + _, err := io.ReadFull(rand.Reader, salt) + if err != nil { + return HashedPassword{}, oops.New(err, "failed to generate salt") + } + saltEnc := base64.StdEncoding.EncodeToString(salt) + + cfg := Argon2idConfig{ + Time: 1, + Memory: 40 * 1024 * 1024, + Threads: 1, + KeyLength: keyLength, + } + + key := argon2.IDKey([]byte(password), salt, cfg.Time, cfg.Memory, cfg.Threads, cfg.KeyLength) + keyEnc := base64.StdEncoding.EncodeToString(key) + + return HashedPassword{ + Algorithm: Argon2id, + AlgoConfig: cfg.String(), + Salt: saltEnc, + Hash: keyEnc, + }, nil +} diff --git a/src/auth/token.go b/src/auth/token.go new file mode 100644 index 0000000..c204cbb --- /dev/null +++ b/src/auth/token.go @@ -0,0 +1,49 @@ +package auth + +import ( + "encoding/json" + "net/http" + + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/oops" +) + +const AuthCookieName = "HMNToken" + +type Token struct { + Username string `json:"username"` +} + +// TODO: ENCRYPT THIS + +func EncodeToken(token Token) string { + tokenBytes, _ := json.Marshal(token) + return string(tokenBytes) +} + +func DecodeToken(tokenStr string) (Token, error) { + var token Token + err := json.Unmarshal([]byte(tokenStr), &token) + if err != nil { + // TODO: Is this worthy of an oops error, or should this just be a value handled silently by code? + return Token{}, oops.New(err, "failed to unmarshal token") + } + + return token, nil +} + +func NewAuthCookie(username string) *http.Cookie { + return &http.Cookie{ + Name: AuthCookieName, + Value: EncodeToken(Token{ + Username: username, + }), + + Domain: config.Config.CookieDomain, + // TODO: Path? + + // Secure: true, + HttpOnly: true, + SameSite: http.SameSiteDefaultMode, + } +} diff --git a/src/config/types.go b/src/config/types.go index 84c3047..692c4d7 100644 --- a/src/config/types.go +++ b/src/config/types.go @@ -15,10 +15,12 @@ const ( ) type HMNConfig struct { - Env Environment - Addr string - BaseUrl string - Postgres PostgresConfig + Env Environment + Addr string + BaseUrl string + Postgres PostgresConfig + CookieDomain string + TokenSecret string } type PostgresConfig struct { diff --git a/src/db/db.go b/src/db/db.go index 16cdeeb..22863ca 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -3,7 +3,6 @@ package db import ( "context" "errors" - "fmt" "reflect" "strings" @@ -61,8 +60,6 @@ func (it *StructQueryIterator) Next(dest interface{}) bool { panic(err) } - fmt.Printf("%#v\n", vals) - for i, val := range vals { field := v.Elem().Field(it.fieldIndices[i]) switch field.Kind() { diff --git a/src/templates/src/include/header.html b/src/templates/src/include/header.html index 84f5165..d4c295e 100644 --- a/src/templates/src/include/header.html +++ b/src/templates/src/include/header.html @@ -1,22 +1,19 @@
- {{/* TODO */}} - {{/*
- {% if user.is_authenticated %} - {% if user.is_superuser %} - Admin - {% endif %} - {{user.username}} - Logout - {% else %} - Register - + {{/* TODO: All the URLs in here are wrong. */}} +
+ {{ if .User }} + {{ if .User.IsSuperuser }} + Admin + {{ end }} + {{ .User.Username }} + Logout + {{ else }} + Register +
-
- {% csrf_token %} + + {{/* TODO: CSRF */}} - {% if error_message %} - - {% endif %} @@ -25,17 +22,15 @@ - - {# #} - + {{/* TODO: Forgot password flow? Or just on standalone page? */}}
{{ error_message }}
Forgot your password?
- {% endif %} -
*/}} + {{ end }} +
" + http.StatusText(code) + ".\n" + fmt.Fprintln(c.Body, body) + } +} + func (c *RequestContext) WriteTemplate(name string, data interface{}) error { return templates.Templates[name].Execute(c.Body, data) } diff --git a/src/website/routes.go b/src/website/routes.go index 423a07f..1210e53 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -2,10 +2,8 @@ package website import ( "context" - "encoding/json" "errors" "fmt" - "io" "net/http" "strings" @@ -130,21 +128,27 @@ func (s *websiteRoutes) ProjectCSS(c *RequestContext, p httprouter.Params) { } func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) { - bodyBytes, _ := io.ReadAll(c.Req.Body) - // TODO: Update this endpoint to give uniform responses on errors and to be resilient to timing attacks. - var body struct { - Username string - Password string - } - err := json.Unmarshal(bodyBytes, &body) + form, err := c.GetFormValues() if err != nil { - panic(err) + c.Errored(http.StatusBadRequest, NewSafeError(err, "request must contain form data")) + return + } + + username := form.Get("username") + password := form.Get("password") + if username == "" || password == "" { + c.Errored(http.StatusBadRequest, NewSafeError(err, "you must provide both a username and password")) + } + + redirect := form.Get("redirect") + if redirect == "" { + redirect = "/" } var user models.User - err = db.QueryOneToStruct(c.Context(), s.conn, &user, "SELECT $columns FROM auth_user WHERE username = $1", body.Username) + err = db.QueryOneToStruct(c.Context(), s.conn, &user, "SELECT $columns FROM auth_user WHERE username = $1", username) if err != nil { if errors.Is(err, db.ErrNoMatchingRows) { c.StatusCode = http.StatusUnauthorized @@ -154,25 +158,26 @@ func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) { return } - logging.Debug().Interface("user", user).Msg("the user to check") - - hashed, err := auth.ParseDjangoPasswordString(user.Password) + hashed, err := auth.ParsePasswordString(user.Password) if err != nil { c.Errored(http.StatusInternalServerError, oops.New(err, "failed to parse password string")) return } - passwordsMatch, err := auth.CheckPassword(body.Password, hashed) + passwordsMatch, err := auth.CheckPassword(password, hashed) if err != nil { c.Errored(http.StatusInternalServerError, oops.New(err, "failed to check password against hash")) return } if passwordsMatch { - c.Body.WriteString("ur good") + logging.Debug().Str("cookie", auth.NewAuthCookie(username).String()).Msg("logged in") + c.SetCookie(auth.NewAuthCookie(username)) + c.Redirect(redirect, http.StatusSeeOther) + return } else { - c.StatusCode = http.StatusUnauthorized - c.Body.WriteString("nope") + c.Redirect("/", http.StatusSeeOther) // TODO: Redirect to standalone login page with error + return } }