osaet/backend/internal/admin/auth_store.go
yarnom f0b50d13ea feat: add admin publishing workflow and yar theme
Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
2026-06-01 15:48:04 +08:00

166 lines
4 KiB
Go

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
}