package admin import ( "context" "database/sql" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) const sessionLifetime = 30 * 24 * time.Hour type LoginInput struct { Username string `json:"username"` Password string `json:"password"` } type LoginResult struct { User User `json:"user"` ExpiresAt time.Time `json:"expiresAt"` Token string `json:"-"` } func (s *Store) CreateOrUpdateUser(ctx context.Context, username string, password string) (User, error) { if username == "" { return User{}, errors.New("username is required") } passwordHash, err := HashPassword(password) if err != nil { return User{}, err } user, err := scanUser(s.db.QueryRow(ctx, ` INSERT INTO users (username, password_hash) VALUES ($1, $2) ON CONFLICT (username) DO UPDATE SET password_hash = excluded.password_hash, updated_at = now() RETURNING id, username, created_at, updated_at, last_login_at`, username, passwordHash)) if err != nil { return User{}, fmt.Errorf("create or update user: %w", err) } return user, nil } func (s *Store) Login(ctx context.Context, input LoginInput) (LoginResult, error) { if input.Username == "" || input.Password == "" { return LoginResult{}, ErrInvalidCredentials } var user User var passwordHash string var lastLoginAt sql.NullTime err := s.db.QueryRow(ctx, ` SELECT id, username, password_hash, created_at, updated_at, last_login_at FROM users WHERE username = $1`, input.Username).Scan( &user.ID, &user.Username, &passwordHash, &user.CreatedAt, &user.UpdatedAt, &lastLoginAt, ) if errors.Is(err, pgx.ErrNoRows) { return LoginResult{}, ErrInvalidCredentials } if err != nil { return LoginResult{}, fmt.Errorf("find user: %w", err) } user.LastLoginAt = nullTimePtr(lastLoginAt) if !CheckPassword(passwordHash, input.Password) { return LoginResult{}, ErrInvalidCredentials } token, err := NewSessionToken() if err != nil { return LoginResult{}, err } loginAt := time.Now() expiresAt := loginAt.Add(sessionLifetime) tx, err := s.db.Begin(ctx) if err != nil { return LoginResult{}, fmt.Errorf("begin login: %w", err) } defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, ` UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = $1`, user.ID); err != nil { return LoginResult{}, fmt.Errorf("update last login: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO admin_sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, user.ID, SessionTokenHash(token), expiresAt); err != nil { return LoginResult{}, fmt.Errorf("create session: %w", err) } if err := tx.Commit(ctx); err != nil { return LoginResult{}, fmt.Errorf("commit login: %w", err) } user.LastLoginAt = &loginAt return LoginResult{ User: user, ExpiresAt: expiresAt, Token: token, }, nil } func (s *Store) UserBySessionToken(ctx context.Context, token string) (User, error) { if token == "" { return User{}, ErrInvalidCredentials } user, err := scanUser(s.db.QueryRow(ctx, ` SELECT u.id, u.username, u.created_at, u.updated_at, u.last_login_at FROM admin_sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > now()`, SessionTokenHash(token))) if errors.Is(err, pgx.ErrNoRows) { return User{}, ErrInvalidCredentials } if err != nil { return User{}, fmt.Errorf("find session: %w", err) } _, _ = s.db.Exec(ctx, ` UPDATE admin_sessions SET last_seen_at = now() WHERE token_hash = $1`, SessionTokenHash(token)) return user, nil } func (s *Store) Logout(ctx context.Context, token string) error { if token == "" { return nil } if _, err := s.db.Exec(ctx, ` DELETE FROM admin_sessions WHERE token_hash = $1`, SessionTokenHash(token)); err != nil { return fmt.Errorf("delete session: %w", err) } return nil } func scanUser(row postScanner) (User, error) { var user User var lastLoginAt sql.NullTime err := row.Scan( &user.ID, &user.Username, &user.CreatedAt, &user.UpdatedAt, &lastLoginAt, ) if err != nil { return User{}, err } user.LastLoginAt = nullTimePtr(lastLoginAt) return user, nil }