add user credentials stuff
This commit is contained in:
96
player/auth.go
Normal file
96
player/auth.go
Normal file
@@ -0,0 +1,96 @@
|
||||
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
|
||||
time = 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, time, 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, time, 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
|
||||
);`
|
57
player/auth_test.go
Normal file
57
player/auth_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package player_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gitlab.com/zephyrtronium/sq"
|
||||
|
||||
"git.sunturtle.xyz/studio/shotgun/player"
|
||||
|
||||
_ "modernc.org/sqlite" // sqlite driver
|
||||
)
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := sq.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := player.InitUsers(ctx, conn); err != nil {
|
||||
t.Fatalf("couldn't init users: %v", err)
|
||||
}
|
||||
|
||||
user, pass := "bocchi", "the rock!"
|
||||
id, err := player.Login(ctx, conn, user, pass)
|
||||
if err == nil {
|
||||
t.Errorf("logging in nonexistent user didn't err")
|
||||
}
|
||||
if id != uuid.Nil {
|
||||
t.Errorf("got nonzero user %v before registering", id)
|
||||
}
|
||||
|
||||
if err := player.Register(ctx, conn, user, pass); err != nil {
|
||||
t.Errorf("failed to register user: %v", err)
|
||||
}
|
||||
|
||||
id, err = player.Login(ctx, conn, user, pass)
|
||||
if err != nil {
|
||||
t.Errorf("couldn't login after registering: %v", err)
|
||||
}
|
||||
if id == uuid.Nil {
|
||||
t.Errorf("got zero player id after registering")
|
||||
}
|
||||
|
||||
wrong, err := player.Login(ctx, conn, user, "not the rock")
|
||||
if err == nil {
|
||||
t.Errorf("logged in with wrong password")
|
||||
}
|
||||
if wrong != uuid.Nil {
|
||||
t.Errorf("got nonzero user %v with wrong password (real is %v)", wrong, id)
|
||||
}
|
||||
}
|
@@ -1,12 +1,7 @@
|
||||
// Package player implements data about players outside games.
|
||||
package player
|
||||
|
||||
import "encoding/hex"
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// ID is a unique ID for a player.
|
||||
// May just be IPv6 (or IPv4-in-6) of their connection, or a UUID.
|
||||
type ID [16]byte
|
||||
|
||||
func (id ID) String() string {
|
||||
return hex.EncodeToString(id[:])
|
||||
}
|
||||
type ID = uuid.UUID
|
||||
|
Reference in New Issue
Block a user