Add initial auth token / login stuff

This commit is contained in:
Ben Visness 2021-03-25 22:33:00 -05:00
parent acca4fe232
commit 56cd737203
12 changed files with 326 additions and 70 deletions

2
go.mod
View File

@ -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

5
go.sum
View File

@ -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=

View File

@ -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
}

49
src/auth/token.go Normal file
View File

@ -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,
}
}

View File

@ -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 {

View File

@ -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() {

View File

@ -1,22 +1,19 @@
<header class="mb3">
{{/* TODO */}}
{{/* <div class="user-options flex justify-center justify-end-ns">
{% if user.is_authenticated %}
{% if user.is_superuser %}
<a class="admin-panel" href="{% url 'admin_panel' subdomain=None %}"><span class="icon-settings"> Admin</span></a>
{% endif %}
<a class="username settings" href="{% url 'member_settings' subdomain=None %}"><span class="icon-settings"></span> {{user.username}}</a>
<a class="logout" href="{% url 'member_logout' %}"><span class="icon-logout"></span> Logout</a>
{% else %}
<a class="register" id="register-link" href="{% url 'member_register' subdomain=None %}">Register</a>
<a class="login" id="login-link" href="{% url 'member_login' subdomain=request.subdomain %}">Log in</a>
{{/* TODO: All the URLs in here are wrong. */}}
<div class="user-options flex justify-center justify-end-ns">
{{ if .User }}
{{ if .User.IsSuperuser }}
<a class="admin-panel" href="{{ url "/admin_panel" }}"><span class="icon-settings"> Admin</span></a>
{{ end }}
<a class="username settings" href="{{ url "/member_settings" }}"><span class="icon-settings"></span> {{ .User.Username }}</a>
<a class="logout" href="{{ url "/member_logout" }}"><span class="icon-logout"></span> Logout</a>
{{ else }}
<a class="register" id="register-link" href="{{ url "/member_register" }}">Register</a>
<a class="login" id="login-link" href="{{ projecturl "/login" }}">Log in</a>
<div id="login-popup">
<form action="{% url 'member_login' subdomain=request.subdomain %}" method="post">
{% csrf_token %}
<form action="{{ projecturl "/login" }}" method="post">
{{/* TODO: CSRF */}}
<table>
{% if error_message %}
<th colspan="2">{{ error_message }}</th>
{% endif %}
<tr>
<th><label>Username:</label></th>
<td><input type="text" name="username" class="textbox username" value="" /></td>
@ -25,17 +22,15 @@
<th><label>Password:</label></th>
<td><input type="password" name="password" class="textbox password" value="" /></td>
</tr>
<tr>
{# <td colspan="2"><a href="#">Forgot your password?</a></td> #}
</tr>
{{/* TODO: Forgot password flow? Or just on standalone page? */}}
</table>
<div class="actionbar pt2">
<input type="submit" value="Log In" />
</div>
</form>
</div>
{% endif %}
</div> */}}
{{ end }}
</div>
<div class="menu-bar flex flex-column flex-row-l justify-between {% if project and project.slug != 'hmn' %}project{% endif %}">
<div class="flex flex-column flex-row-ns">
<a href="{{ url "/" }}" class="logo hmdev-logo">
@ -72,8 +67,8 @@
{{ end }} */}}
</div>
</div>
<form class="dn ma0 flex-l flex-column justify-center items-end" method="post" action="{% url 'search' %}" target="_blank">
{% csrf_token %}
<form class="dn ma0 flex-l flex-column justify-center items-end" method="post" action="{{ url "/search" }}" target="_blank">
{{/* TODO: CSRF? */}}
<input class="site-search bn lite pa2 fira" type="text" name="term" value="" placeholder="Search with Google" size="17" />
<input id="search_button_homepage" type="submit" value="Go"/>
</form>

View File

@ -49,10 +49,6 @@ func Init() {
Templates[f.Name()] = t
}
}
for name, t := range Templates {
fmt.Printf("%s: %v\n", name, names(t.Templates()))
}
}
func names(ts []*template.Template) []string {

View File

@ -5,9 +5,11 @@ type BaseData struct {
CanonicalLink string
OpenGraphItems []OpenGraphItem
BackgroundImage BackgroundImage
Project Project
Theme string
BodyClasses []string
Project Project
User *User
}
type Project struct {
@ -23,6 +25,13 @@ type Project struct {
HasLibrary bool
}
type User struct {
Username string
Email string
IsSuperuser bool
IsStaff bool
}
type OpenGraphItem struct {
Property string
Name string

24
src/website/errors.go Normal file
View File

@ -0,0 +1,24 @@
package website
import "fmt"
// A SafeError can be used to wrap another error and explicitly provide
// an error message that is safe to show to a user. This allows the original
// error to easily be logged and for servers to consistently return errors
// in a standard format, without having to worry about leaking sensitive
// info (assuming you use the right middleware!).
type SafeError struct {
Wrapped error
Msg string
}
func NewSafeError(err error, msg string, args ...interface{}) error {
return &SafeError{
Wrapped: err,
Msg: fmt.Sprintf(msg, args...),
}
}
func (s *SafeError) Error() string {
return s.Msg
}

View File

@ -3,9 +3,13 @@ package website
import (
"bytes"
"context"
"fmt"
"html"
"io"
"net/http"
"net/url"
"path"
"strings"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models"
@ -90,6 +94,80 @@ func (c *RequestContext) Headers() http.Header {
return c.rw.Header()
}
func (c *RequestContext) SetCookie(cookie *http.Cookie) {
c.rw.Header().Add("Set-Cookie", cookie.String())
}
func (c *RequestContext) GetFormValues() (url.Values, error) {
err := c.Req.ParseForm()
if err != nil {
return nil, err
}
return c.Req.PostForm, nil
}
// The logic of this function is copy-pasted from the Go standard library.
// https://golang.org/pkg/net/http/#Redirect
func (c *RequestContext) Redirect(dest string, code int) {
if u, err := url.Parse(dest); err == nil {
// If url was relative, make its path absolute by
// combining with request path.
// The client would probably do this for us,
// but doing it ourselves is more reliable.
// See RFC 7231, section 7.1.2
if u.Scheme == "" && u.Host == "" {
oldpath := c.Req.URL.Path
if oldpath == "" { // should not happen, but avoid a crash if it does
oldpath = "/"
}
// no leading http://server
if dest == "" || dest[0] != '/' {
// make relative path absolute
olddir, _ := path.Split(oldpath)
dest = olddir + dest
}
var query string
if i := strings.Index(dest, "?"); i != -1 {
dest, query = dest[:i], dest[i:]
}
// clean up but preserve trailing slash
trailing := strings.HasSuffix(dest, "/")
dest = path.Clean(dest)
if trailing && !strings.HasSuffix(dest, "/") {
dest += "/"
}
dest += query
}
}
h := c.Headers()
// RFC 7231 notes that a short HTML body is usually included in
// the response because older user agents may not understand 301/307.
// Do it only if the request didn't already have a Content-Type header.
_, hadCT := h["Content-Type"]
// Escape stuff
destUrl, _ := url.Parse(dest)
dest = destUrl.String()
h.Set("Location", dest)
if !hadCT && (c.Req.Method == "GET" || c.Req.Method == "HEAD") {
h.Set("Content-Type", "text/html; charset=utf-8")
}
c.StatusCode = code
// Shouldn't send the body for POST or HEAD; that leaves GET.
if !hadCT && c.Req.Method == "GET" {
body := "<a href=\"" + html.EscapeString(dest) + "\">" + http.StatusText(code) + "</a>.\n"
fmt.Fprintln(c.Body, body)
}
}
func (c *RequestContext) WriteTemplate(name string, data interface{}) error {
return templates.Templates[name].Execute(c.Body, data)
}

View File

@ -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
}
}