auth: Registrierung, Login, Logout, per-user Sessions (Backend)
This commit is contained in:
parent
c7b4acea02
commit
0ef39cffd6
12 changed files with 703 additions and 40 deletions
112
internal/store/users.go
Normal file
112
internal/store/users.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrUserExists = errors.New("username already taken")
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
hash string
|
||||
}
|
||||
|
||||
func (u User) PasswordHash() string { return u.hash }
|
||||
|
||||
// CreateUser inserts a user. The first user ever created adopts all sessions
|
||||
// that existed before accounts were introduced (user_id 0).
|
||||
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (User, error) {
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var n int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `INSERT INTO users(username,password_hash,created_at) VALUES(?,?,?)`, username, passwordHash, now.UnixMilli())
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return User{}, ErrUserExists
|
||||
}
|
||||
return User{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
if n == 0 {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sessions SET user_id=? WHERE user_id=0`, id); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
}
|
||||
return User{ID: id, Username: username, CreatedAt: now, hash: passwordHash}, tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) UserByName(ctx context.Context, username string) (User, error) {
|
||||
var u User
|
||||
var c int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id,username,password_hash,created_at FROM users WHERE username=?`, username).Scan(&u.ID, &u.Username, &u.hash, &c)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return u, ErrNotFound
|
||||
}
|
||||
u.CreatedAt = time.UnixMilli(c).UTC()
|
||||
return u, err
|
||||
}
|
||||
|
||||
func hashToken(t string) string {
|
||||
h := sha256.Sum256([]byte(t))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// IssueToken creates a random opaque token; only its SHA-256 is stored.
|
||||
func (s *Store) IssueToken(ctx context.Context, userID int64, ttl time.Duration) (string, time.Time, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
tok := "bc_" + base64.RawURLEncoding.EncodeToString(b)
|
||||
now := time.Now().UTC()
|
||||
exp := now.Add(ttl)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO auth_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`,
|
||||
hashToken(tok), userID, now.UnixMilli(), exp.UnixMilli())
|
||||
s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE expires_at<?`, now.UnixMilli())
|
||||
return tok, exp, err
|
||||
}
|
||||
|
||||
// UserByToken resolves a non-expired token (lookup by hash, so no timing leak on the secret).
|
||||
func (s *Store) UserByToken(ctx context.Context, tok string) (User, error) {
|
||||
var u User
|
||||
var c int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.created_at FROM auth_tokens t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.token_hash=? AND t.expires_at>?`, hashToken(tok), time.Now().UTC().UnixMilli()).Scan(&u.ID, &u.Username, &c)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return u, ErrNotFound
|
||||
}
|
||||
u.CreatedAt = time.UnixMilli(c).UTC()
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Store) RevokeToken(ctx context.Context, tok string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE token_hash=?`, hashToken(tok))
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionOwnedBy returns ErrNotFound unless the session exists and belongs to userID (AllUsers = any).
|
||||
func (s *Store) SessionOwnedBy(ctx context.Context, userID int64, id string) error {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions WHERE id=? AND (?<0 OR user_id=?)`, id, userID, userID).Scan(&n)
|
||||
if err == nil && n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue