Add initial auth token / login stuff
This commit is contained in:
parent
acca4fe232
commit
56cd737203
2
go.mod
2
go.mod
|
@ -18,7 +18,7 @@ require (
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
github.com/teacat/noire v1.1.0
|
github.com/teacat/noire v1.1.0
|
||||||
github.com/wellington/go-libsass v0.9.2
|
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
|
replace github.com/rs/zerolog v1.20.0 => github.com/bvisness/zerolog v1.20.1-0.20210321191248-05f63bf0e9e0
|
||||||
|
|
5
go.sum
5
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-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-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-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-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
|
||||||
golang.org/x/crypto v0.0.0-20210317152858-513c2a44f670/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
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-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-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
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-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-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-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/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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
|
124
src/auth/auth.go
124
src/auth/auth.go
|
@ -2,61 +2,132 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
"golang.org/x/crypto/pbkdf2"
|
"golang.org/x/crypto/pbkdf2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HashAlgorithm string
|
type HashAlgorithm string
|
||||||
|
|
||||||
const (
|
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 {
|
type HashedPassword struct {
|
||||||
Algorithm HashAlgorithm
|
Algorithm HashAlgorithm
|
||||||
Iterations int
|
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
|
Salt string
|
||||||
Hash string
|
Hash string
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseDjangoPasswordString(s string) (HashedPassword, error) {
|
func ParsePasswordString(s string) (HashedPassword, error) {
|
||||||
pieces := strings.SplitN(s, "$", 4)
|
pieces := strings.SplitN(s, "$", 4)
|
||||||
if len(pieces) < 4 {
|
if len(pieces) < 4 {
|
||||||
return HashedPassword{}, oops.New(nil, "unrecognized password string format")
|
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{
|
return HashedPassword{
|
||||||
Algorithm: HashAlgorithm(pieces[0]),
|
Algorithm: HashAlgorithm(pieces[0]),
|
||||||
Iterations: iterations,
|
AlgoConfig: pieces[1],
|
||||||
Salt: pieces[2],
|
Salt: pieces[2],
|
||||||
Hash: pieces[3],
|
Hash: pieces[3],
|
||||||
}, nil
|
}, 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) {
|
func CheckPassword(password string, hashedPassword HashedPassword) (bool, error) {
|
||||||
switch hashedPassword.Algorithm {
|
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)
|
decoded, err := base64.StdEncoding.DecodeString(hashedPassword.Hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, oops.New(nil, "failed to get key length of hashed password")
|
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(
|
newHash := pbkdf2.Key(
|
||||||
[]byte(password),
|
[]byte(password),
|
||||||
[]byte(hashedPassword.Salt),
|
[]byte(hashedPassword.Salt),
|
||||||
hashedPassword.Iterations,
|
iterations,
|
||||||
len(decoded),
|
len(decoded),
|
||||||
sha256.New,
|
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)
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,6 +19,8 @@ type HMNConfig struct {
|
||||||
Addr string
|
Addr string
|
||||||
BaseUrl string
|
BaseUrl string
|
||||||
Postgres PostgresConfig
|
Postgres PostgresConfig
|
||||||
|
CookieDomain string
|
||||||
|
TokenSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostgresConfig struct {
|
type PostgresConfig struct {
|
||||||
|
|
|
@ -3,7 +3,6 @@ package db
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
@ -61,8 +60,6 @@ func (it *StructQueryIterator) Next(dest interface{}) bool {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%#v\n", vals)
|
|
||||||
|
|
||||||
for i, val := range vals {
|
for i, val := range vals {
|
||||||
field := v.Elem().Field(it.fieldIndices[i])
|
field := v.Elem().Field(it.fieldIndices[i])
|
||||||
switch field.Kind() {
|
switch field.Kind() {
|
||||||
|
|
|
@ -1,22 +1,19 @@
|
||||||
<header class="mb3">
|
<header class="mb3">
|
||||||
{{/* TODO */}}
|
{{/* TODO: All the URLs in here are wrong. */}}
|
||||||
{{/* <div class="user-options flex justify-center justify-end-ns">
|
<div class="user-options flex justify-center justify-end-ns">
|
||||||
{% if user.is_authenticated %}
|
{{ if .User }}
|
||||||
{% if user.is_superuser %}
|
{{ if .User.IsSuperuser }}
|
||||||
<a class="admin-panel" href="{% url 'admin_panel' subdomain=None %}"><span class="icon-settings"> Admin</span></a>
|
<a class="admin-panel" href="{{ url "/admin_panel" }}"><span class="icon-settings"> Admin</span></a>
|
||||||
{% endif %}
|
{{ end }}
|
||||||
<a class="username settings" href="{% url 'member_settings' subdomain=None %}"><span class="icon-settings"></span> {{user.username}}</a>
|
<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>
|
<a class="logout" href="{{ url "/member_logout" }}"><span class="icon-logout"></span> Logout</a>
|
||||||
{% else %}
|
{{ else }}
|
||||||
<a class="register" id="register-link" href="{% url 'member_register' subdomain=None %}">Register</a>
|
<a class="register" id="register-link" href="{{ url "/member_register" }}">Register</a>
|
||||||
<a class="login" id="login-link" href="{% url 'member_login' subdomain=request.subdomain %}">Log in</a>
|
<a class="login" id="login-link" href="{{ projecturl "/login" }}">Log in</a>
|
||||||
<div id="login-popup">
|
<div id="login-popup">
|
||||||
<form action="{% url 'member_login' subdomain=request.subdomain %}" method="post">
|
<form action="{{ projecturl "/login" }}" method="post">
|
||||||
{% csrf_token %}
|
{{/* TODO: CSRF */}}
|
||||||
<table>
|
<table>
|
||||||
{% if error_message %}
|
|
||||||
<th colspan="2">{{ error_message }}</th>
|
|
||||||
{% endif %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<th><label>Username:</label></th>
|
<th><label>Username:</label></th>
|
||||||
<td><input type="text" name="username" class="textbox username" value="" /></td>
|
<td><input type="text" name="username" class="textbox username" value="" /></td>
|
||||||
|
@ -25,17 +22,15 @@
|
||||||
<th><label>Password:</label></th>
|
<th><label>Password:</label></th>
|
||||||
<td><input type="password" name="password" class="textbox password" value="" /></td>
|
<td><input type="password" name="password" class="textbox password" value="" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
{{/* TODO: Forgot password flow? Or just on standalone page? */}}
|
||||||
{# <td colspan="2"><a href="#">Forgot your password?</a></td> #}
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
<div class="actionbar pt2">
|
<div class="actionbar pt2">
|
||||||
<input type="submit" value="Log In" />
|
<input type="submit" value="Log In" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{{ end }}
|
||||||
</div> */}}
|
</div>
|
||||||
<div class="menu-bar flex flex-column flex-row-l justify-between {% if project and project.slug != 'hmn' %}project{% endif %}">
|
<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">
|
<div class="flex flex-column flex-row-ns">
|
||||||
<a href="{{ url "/" }}" class="logo hmdev-logo">
|
<a href="{{ url "/" }}" class="logo hmdev-logo">
|
||||||
|
@ -72,8 +67,8 @@
|
||||||
{{ end }} */}}
|
{{ end }} */}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form class="dn ma0 flex-l flex-column justify-center items-end" method="post" action="{% url 'search' %}" target="_blank">
|
<form class="dn ma0 flex-l flex-column justify-center items-end" method="post" action="{{ url "/search" }}" target="_blank">
|
||||||
{% csrf_token %}
|
{{/* TODO: CSRF? */}}
|
||||||
<input class="site-search bn lite pa2 fira" type="text" name="term" value="" placeholder="Search with Google" size="17" />
|
<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"/>
|
<input id="search_button_homepage" type="submit" value="Go"/>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -49,10 +49,6 @@ func Init() {
|
||||||
Templates[f.Name()] = t
|
Templates[f.Name()] = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, t := range Templates {
|
|
||||||
fmt.Printf("%s: %v\n", name, names(t.Templates()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func names(ts []*template.Template) []string {
|
func names(ts []*template.Template) []string {
|
||||||
|
|
|
@ -5,9 +5,11 @@ type BaseData struct {
|
||||||
CanonicalLink string
|
CanonicalLink string
|
||||||
OpenGraphItems []OpenGraphItem
|
OpenGraphItems []OpenGraphItem
|
||||||
BackgroundImage BackgroundImage
|
BackgroundImage BackgroundImage
|
||||||
Project Project
|
|
||||||
Theme string
|
Theme string
|
||||||
BodyClasses []string
|
BodyClasses []string
|
||||||
|
|
||||||
|
Project Project
|
||||||
|
User *User
|
||||||
}
|
}
|
||||||
|
|
||||||
type Project struct {
|
type Project struct {
|
||||||
|
@ -23,6 +25,13 @@ type Project struct {
|
||||||
HasLibrary bool
|
HasLibrary bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
IsSuperuser bool
|
||||||
|
IsStaff bool
|
||||||
|
}
|
||||||
|
|
||||||
type OpenGraphItem struct {
|
type OpenGraphItem struct {
|
||||||
Property string
|
Property string
|
||||||
Name string
|
Name string
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -3,9 +3,13 @@ package website
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/logging"
|
"git.handmade.network/hmn/hmn/src/logging"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
|
@ -90,6 +94,80 @@ func (c *RequestContext) Headers() http.Header {
|
||||||
return c.rw.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 {
|
func (c *RequestContext) WriteTemplate(name string, data interface{}) error {
|
||||||
return templates.Templates[name].Execute(c.Body, data)
|
return templates.Templates[name].Execute(c.Body, data)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,8 @@ package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
@ -130,21 +128,27 @@ func (s *websiteRoutes) ProjectCSS(c *RequestContext, p httprouter.Params) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *websiteRoutes) Login(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.
|
// TODO: Update this endpoint to give uniform responses on errors and to be resilient to timing attacks.
|
||||||
|
|
||||||
var body struct {
|
form, err := c.GetFormValues()
|
||||||
Username string
|
|
||||||
Password string
|
|
||||||
}
|
|
||||||
err := json.Unmarshal(bodyBytes, &body)
|
|
||||||
if err != nil {
|
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
|
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 err != nil {
|
||||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
c.StatusCode = http.StatusUnauthorized
|
c.StatusCode = http.StatusUnauthorized
|
||||||
|
@ -154,25 +158,26 @@ func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logging.Debug().Interface("user", user).Msg("the user to check")
|
hashed, err := auth.ParsePasswordString(user.Password)
|
||||||
|
|
||||||
hashed, err := auth.ParseDjangoPasswordString(user.Password)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Errored(http.StatusInternalServerError, oops.New(err, "failed to parse password string"))
|
c.Errored(http.StatusInternalServerError, oops.New(err, "failed to parse password string"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
passwordsMatch, err := auth.CheckPassword(body.Password, hashed)
|
passwordsMatch, err := auth.CheckPassword(password, hashed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Errored(http.StatusInternalServerError, oops.New(err, "failed to check password against hash"))
|
c.Errored(http.StatusInternalServerError, oops.New(err, "failed to check password against hash"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if passwordsMatch {
|
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 {
|
} else {
|
||||||
c.StatusCode = http.StatusUnauthorized
|
c.Redirect("/", http.StatusSeeOther) // TODO: Redirect to standalone login page with error
|
||||||
c.Body.WriteString("nope")
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue