205 lines
6.1 KiB
Go
205 lines
6.1 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/pbkdf2"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
passwordIterations = 310_000
|
|
passwordSaltBytes = 16
|
|
passwordKeyBytes = 32
|
|
sessionCookie = "pocketwatch_session"
|
|
)
|
|
|
|
// This is deliberately public and never authenticates an account. It only keeps
|
|
// failed-login password work roughly constant when a username does not exist.
|
|
const dummyPasswordHash = "pbkdf2-sha256$310000$cG9ja2V0d2F0Y2gtZHVtbXk$y2kfBbJVOW/xpojEt9AAGQfhF+Ul6iGKDKH09PSY7IY"
|
|
|
|
type session struct {
|
|
User User
|
|
CSRF string
|
|
}
|
|
|
|
type contextKey int
|
|
|
|
const sessionKey contextKey = 1
|
|
|
|
func hashPassword(password string) (string, error) {
|
|
if len(password) < 10 {
|
|
return "", fmt.Errorf("password must have at least 10 characters")
|
|
}
|
|
salt := make([]byte, passwordSaltBytes)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return "", err
|
|
}
|
|
key := pbkdf2SHA256([]byte(password), salt, passwordIterations, passwordKeyBytes)
|
|
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", passwordIterations, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
|
|
}
|
|
|
|
func verifyPassword(encoded, password string) bool {
|
|
parts := strings.Split(encoded, "$")
|
|
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
|
return false
|
|
}
|
|
iter, err := strconv.Atoi(parts[1])
|
|
if err != nil || iter < 100_000 || iter > 2_000_000 {
|
|
return false
|
|
}
|
|
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
want, err := base64.RawStdEncoding.DecodeString(parts[3])
|
|
if err != nil || len(want) == 0 {
|
|
return false
|
|
}
|
|
got := pbkdf2SHA256([]byte(password), salt, iter, len(want))
|
|
return subtle.ConstantTimeCompare(got, want) == 1
|
|
}
|
|
|
|
// pbkdf2SHA256 wraps Go 1.26's standard-library PBKDF2 implementation.
|
|
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
|
|
key, err := pbkdf2.Key(sha256.New, string(password), salt, iterations, keyLen)
|
|
if err != nil {
|
|
panic(err) // callers use fixed, validated parameters
|
|
}
|
|
return key
|
|
}
|
|
|
|
func randomToken(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
func tokenDigest(token string) []byte {
|
|
s := sha256.Sum256([]byte(token))
|
|
return s[:]
|
|
}
|
|
|
|
func newID() string {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
h := hex.EncodeToString(b)
|
|
return h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
|
|
}
|
|
|
|
func (a *App) createSession(ctx context.Context, userID string) (token, csrf string, expires time.Time, err error) {
|
|
token, err = randomToken(32)
|
|
if err != nil {
|
|
return
|
|
}
|
|
csrf, err = randomToken(24)
|
|
if err != nil {
|
|
return
|
|
}
|
|
now := time.Now()
|
|
expires = now.Add(a.cfg.SessionTTL)
|
|
_, err = a.store.db.ExecContext(ctx, `INSERT INTO sessions(token_hash,user_id,csrf_token,created_at_ms,expires_at_ms) VALUES(?,?,?,?,?)`, tokenDigest(token), userID, csrf, now.UnixMilli(), expires.UnixMilli())
|
|
return
|
|
}
|
|
|
|
func (a *App) sessionFromRequest(r *http.Request) (*session, error) {
|
|
c, err := r.Cookie(sessionCookie)
|
|
if err != nil || c.Value == "" {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
var userID, csrf string
|
|
var expires int64
|
|
err = a.store.db.QueryRowContext(r.Context(), `SELECT user_id,csrf_token,expires_at_ms FROM sessions WHERE token_hash=?`, tokenDigest(c.Value)).Scan(&userID, &csrf, &expires)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if expires <= time.Now().UnixMilli() {
|
|
_, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, tokenDigest(c.Value))
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
u, err := a.store.userByID(r.Context(), userID)
|
|
if err != nil || !u.Active {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
return &session{User: u, CSRF: csrf}, nil
|
|
}
|
|
|
|
func (a *App) withSession(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
s, err := a.sessionFromRequest(r)
|
|
if err != nil {
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
jsonError(w, http.StatusUnauthorized, "not_authenticated", "Bitte anmelden.")
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey, s)))
|
|
})
|
|
}
|
|
|
|
func sessionOf(r *http.Request) *session {
|
|
x, _ := r.Context().Value(sessionKey).(*session)
|
|
return x
|
|
}
|
|
|
|
func requireCSRF(w http.ResponseWriter, r *http.Request) bool {
|
|
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
|
return true
|
|
}
|
|
s := sessionOf(r)
|
|
if s == nil || subtle.ConstantTimeCompare([]byte(r.Header.Get("X-CSRF-Token")), []byte(s.CSRF)) != 1 {
|
|
jsonError(w, http.StatusForbidden, "csrf", "Ungültiges CSRF-Token.")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
|
s := sessionOf(r)
|
|
if s == nil || s.User.Role != "admin" {
|
|
jsonError(w, http.StatusForbidden, "forbidden", "Admin-Rechte erforderlich.")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (a *App) deleteCurrentSession(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie(sessionCookie); err == nil {
|
|
_, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, tokenDigest(c.Value))
|
|
}
|
|
a.clearSessionCookie(w)
|
|
}
|
|
|
|
func (a *App) setSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())})
|
|
}
|
|
|
|
func (a *App) clearSessionCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0)})
|
|
}
|
|
|
|
func isUniqueConstraint(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
s := strings.ToLower(err.Error())
|
|
return strings.Contains(s, "unique constraint") || strings.Contains(s, "constraint failed")
|
|
}
|