Some checks failed
Deploy to NAS / deploy (push) Failing after 26s
- Public feed (/) with infinite scroll via Intersection Observer - Self-registration (/register) - Admin area (/admin/entries, /admin/users) with user management - journal_entries: visibility (public/private) + hashtags fields - users: is_admin flag - DB schema updated (recreate DB to apply) - CI: run go test via docker run (golang:1.25-alpine) — fixes 'go not found' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
173 lines
4.7 KiB
Go
173 lines
4.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"golang.org/x/crypto/argon2"
|
|
|
|
"github.com/jacek/pamietnik/backend/internal/domain"
|
|
)
|
|
|
|
const sessionDuration = 24 * time.Hour
|
|
|
|
var ErrInvalidCredentials = errors.New("invalid username or password")
|
|
var ErrSessionNotFound = errors.New("session not found or expired")
|
|
var ErrUsernameTaken = errors.New("username already taken")
|
|
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewStore(pool *pgxpool.Pool) *Store {
|
|
return &Store{pool: pool}
|
|
}
|
|
|
|
// HashPassword returns an argon2id hash of the password.
|
|
func HashPassword(password string) (string, error) {
|
|
salt := make([]byte, 16)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return "", fmt.Errorf("generate salt: %w", err)
|
|
}
|
|
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
|
|
return fmt.Sprintf("$argon2id$%x$%x", salt, hash), nil
|
|
}
|
|
|
|
// VerifyPassword checks password against stored hash.
|
|
// Format: $argon2id$<saltHex>$<hashHex>
|
|
func VerifyPassword(password, stored string) bool {
|
|
parts := strings.Split(stored, "$")
|
|
// ["", "argon2id", "<saltHex>", "<hashHex>"]
|
|
if len(parts) != 4 || parts[1] != "argon2id" {
|
|
return false
|
|
}
|
|
salt, err := hex.DecodeString(parts[2])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
expected, err := hex.DecodeString(parts[3])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
|
|
return subtle.ConstantTimeCompare(hash, expected) == 1
|
|
}
|
|
|
|
// Login verifies credentials and creates a session.
|
|
func (s *Store) Login(ctx context.Context, username, password string) (domain.Session, error) {
|
|
var user domain.User
|
|
err := s.pool.QueryRow(ctx,
|
|
`SELECT user_id, username, password_hash FROM users WHERE username = $1`,
|
|
username,
|
|
).Scan(&user.UserID, &user.Username, &user.PasswordHash)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.Session{}, ErrInvalidCredentials
|
|
}
|
|
return domain.Session{}, err
|
|
}
|
|
|
|
if !VerifyPassword(password, user.PasswordHash) {
|
|
return domain.Session{}, ErrInvalidCredentials
|
|
}
|
|
|
|
sessionID, err := newSessionID()
|
|
if err != nil {
|
|
return domain.Session{}, fmt.Errorf("create session: %w", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
sess := domain.Session{
|
|
SessionID: sessionID,
|
|
UserID: user.UserID,
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(sessionDuration),
|
|
}
|
|
|
|
_, err = s.pool.Exec(ctx,
|
|
`INSERT INTO sessions (session_id, user_id, created_at, expires_at)
|
|
VALUES ($1, $2, $3, $4)`,
|
|
sess.SessionID, sess.UserID, sess.CreatedAt, sess.ExpiresAt,
|
|
)
|
|
if err != nil {
|
|
return domain.Session{}, err
|
|
}
|
|
return sess, nil
|
|
}
|
|
|
|
// GetSession validates a session and returns user_id.
|
|
func (s *Store) GetSession(ctx context.Context, sessionID string) (domain.Session, error) {
|
|
var sess domain.Session
|
|
err := s.pool.QueryRow(ctx,
|
|
`SELECT session_id, user_id, created_at, expires_at
|
|
FROM sessions
|
|
WHERE session_id = $1 AND expires_at > NOW()`,
|
|
sessionID,
|
|
).Scan(&sess.SessionID, &sess.UserID, &sess.CreatedAt, &sess.ExpiresAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.Session{}, ErrSessionNotFound
|
|
}
|
|
return domain.Session{}, err
|
|
}
|
|
return sess, nil
|
|
}
|
|
|
|
// Logout deletes a session.
|
|
func (s *Store) Logout(ctx context.Context, sessionID string) error {
|
|
_, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE session_id = $1`, sessionID)
|
|
if err != nil {
|
|
return fmt.Errorf("delete session: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Register creates a new user account. Returns ErrUsernameTaken if the username is already in use.
|
|
func (s *Store) Register(ctx context.Context, username, password string) error {
|
|
hash, err := HashPassword(password)
|
|
if err != nil {
|
|
return fmt.Errorf("hash password: %w", err)
|
|
}
|
|
_, err = s.pool.Exec(ctx,
|
|
`INSERT INTO users (username, password_hash) VALUES ($1, $2)`,
|
|
username, hash,
|
|
)
|
|
if err != nil && strings.Contains(err.Error(), "unique") {
|
|
return ErrUsernameTaken
|
|
}
|
|
return err
|
|
}
|
|
|
|
// GetUserBySession returns the full user (including is_admin) for a session.
|
|
func (s *Store) GetUserBySession(ctx context.Context, sessionID string) (domain.User, error) {
|
|
var u domain.User
|
|
err := s.pool.QueryRow(ctx,
|
|
`SELECT u.user_id, u.username, u.is_admin, u.created_at
|
|
FROM sessions s JOIN users u ON s.user_id = u.user_id
|
|
WHERE s.session_id = $1 AND s.expires_at > NOW()`,
|
|
sessionID,
|
|
).Scan(&u.UserID, &u.Username, &u.IsAdmin, &u.CreatedAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return domain.User{}, ErrSessionNotFound
|
|
}
|
|
return domain.User{}, err
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func newSessionID() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("generate session id: %w", err)
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|