package player import ( "bytes" "context" "crypto/rand" "errors" "fmt" "log/slog" "strings" "github.com/google/uuid" "gitlab.com/zephyrtronium/sq" "golang.org/x/crypto/argon2" ) const ( // Argon2id parameters recommended in // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id timeCost = 3 memory = 12 << 10 threads = 1 hashLen = 48 ) type ( Execer interface { Exec(ctx context.Context, query string, args ...any) (sq.Result, error) } RowQuerier interface { QueryRow(ctx context.Context, query string, args ...any) *sq.Row } ) // InitUsers initializes the table for permanent storage of user credentials. func InitUsers(ctx context.Context, db Execer) error { _, err := db.Exec(ctx, initUsers) if err != nil { return fmt.Errorf("couldn't init users table: %w", err) } return nil } // Register adds a new user to a database initialized with [InitUsers]. func Register(ctx context.Context, db Execer, user, pass string) error { user = strings.ToLower(user) // TODO(zeph): validate length and content slog.InfoContext(ctx, "register user", "user", user) salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { slog.ErrorContext(ctx, "failed to get salt for new user", "user", user, "err", err.Error()) panic(err) } h := argon2.IDKey([]byte(pass), salt, timeCost, memory, threads, hashLen) id := uuid.New() _, err := db.Exec(ctx, `INSERT INTO shotgun_users(user, pass, salt, id) VALUES (?, ?, ?, ?)`, user, h, salt, id) if err != nil { slog.ErrorContext(ctx, "failed to register user", "user", user, "err", err.Error()) return fmt.Errorf("couldn't register new user: %w", err) } slog.InfoContext(ctx, "registered user", "user", user, "id", id) return nil } // Login gets the user ID associcated with a user if their saved credentials // match those provided. func Login(ctx context.Context, db RowQuerier, user, pass string) (ID, error) { // TODO(zeph): we are making almost no attempt to distinguish // "user does not exist" from "wrong password" user = strings.ToLower(user) slog.InfoContext(ctx, "login", "user", user) var ( p, salt []byte id uuid.UUID ) if err := db.QueryRow(ctx, `SELECT pass, salt, id FROM shotgun_users WHERE user = ?`, user).Scan(&p, &salt, &id); err != nil { slog.ErrorContext(ctx, "failed to get user creds", "user", user, "err", err.Error()) if errors.Is(err, sq.ErrNoRows) { return ID{}, fmt.Errorf("invalid credentials") } return ID{}, fmt.Errorf("couldn't get user creds: %w", err) } h := argon2.IDKey([]byte(pass), salt, timeCost, memory, threads, hashLen) if !bytes.Equal(p, h) { slog.ErrorContext(ctx, "login failed", "user", user) return ID{}, fmt.Errorf("invalid credentials") } return ID{id}, nil } const initUsers = `CREATE TABLE shotgun_users ( user TEXT PRIMARY KEY NOT NULL, pass BLOB NOT NULL, salt BLOB NOT NULL, id TEXT NOT NULL UNIQUE ) STRICT;`