init: initial commit
Checks / verify (push) Canceled after 41s

This commit is contained in:
2026-10-06 11:24:02 +08:00
parent 456e87d4f2
commit ba6dfffab7
63 changed files with 7223 additions and 0 deletions
@@ -0,0 +1,27 @@
package domain
import (
"time"
)
type Credential struct {
ID string `db:"id"`
UserID string `db:"user_id"`
LoginName string `db:"login_name"`
PasswordHash string `db:"password_hash" json:"-"`
CreatedAt time.Time `db:"created_at"`
}
func (Credential) TableName() string { return "veyra_credential" }
type Session struct {
ID string `db:"id"`
UserID string `db:"user_id"`
TokenHash string `db:"token_hash" json:"-"`
Audience string `db:"audience"`
ExpiresAt time.Time `db:"expires_at"`
RevokedAt *time.Time `db:"revoked_at"`
CreatedAt time.Time `db:"created_at"`
}
func (Session) TableName() string { return "veyra_session" }
@@ -0,0 +1,15 @@
package domain
import "time"
// User maps explicitly to veyra_user. IDs are canonical UUID strings.
type User struct {
ID string `db:"id" json:"id"`
DisplayName string `db:"display_name" json:"display_name"`
Status string `db:"status" json:"status"`
Role string `db:"role" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (User) TableName() string { return "veyra_user" }
@@ -0,0 +1,94 @@
package repository
import (
"context"
"errors"
"time"
"veyra/services/api/internal/platform/apperror"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"veyra/services/api/internal/identity/domain"
)
type Postgres struct{ pool *pgxpool.Pool }
func NewPostgres(pool *pgxpool.Pool) *Postgres { return &Postgres{pool: pool} }
func (r *Postgres) CreateUser(ctx context.Context, user domain.User, credential domain.Credential) (domain.User, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.User{}, apperror.ErrUnavailable
}
defer tx.Rollback(context.Background())
err = tx.QueryRow(ctx, `INSERT INTO veyra_user(id,display_name,status,role) VALUES($1,$2,$3,$4) RETURNING created_at,updated_at`, user.ID, user.DisplayName, user.Status, user.Role).Scan(&user.CreatedAt, &user.UpdatedAt)
if err != nil {
return domain.User{}, mapError(err)
}
_, err = tx.Exec(ctx, `INSERT INTO veyra_credential(id,user_id,login_name,password_hash) VALUES($1,$2,$3,$4)`, credential.ID, credential.UserID, credential.LoginName, credential.PasswordHash)
if err != nil {
return domain.User{}, mapError(err)
}
if err = tx.Commit(ctx); err != nil {
return domain.User{}, apperror.ErrUnavailable
}
return user, nil
}
func (r *Postgres) FindCredential(ctx context.Context, login string) (domain.User, domain.Credential, error) {
var u domain.User
var c domain.Credential
err := r.pool.QueryRow(ctx, `SELECT u.id,u.display_name,u.status,u.role,u.created_at,u.updated_at,c.id,c.user_id,c.login_name,c.password_hash,c.created_at FROM veyra_user u JOIN veyra_credential c ON c.user_id=u.id WHERE c.login_name=$1`, login).Scan(&u.ID, &u.DisplayName, &u.Status, &u.Role, &u.CreatedAt, &u.UpdatedAt, &c.ID, &c.UserID, &c.LoginName, &c.PasswordHash, &c.CreatedAt)
return u, c, mapError(err)
}
func (r *Postgres) CreateSession(ctx context.Context, s domain.Session) error {
_, err := r.pool.Exec(ctx, `INSERT INTO veyra_session(id,user_id,token_hash,audience,expires_at) VALUES($1,$2,$3,$4,$5)`, s.ID, s.UserID, s.TokenHash, s.Audience, s.ExpiresAt)
return mapError(err)
}
func (r *Postgres) FindSessionUser(ctx context.Context, hash, audience string, now time.Time) (domain.User, error) {
var u domain.User
err := r.pool.QueryRow(ctx, `SELECT u.id,u.display_name,u.status,u.role,u.created_at,u.updated_at FROM veyra_session s JOIN veyra_user u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.audience=$2 AND s.expires_at>$3 AND s.revoked_at IS NULL AND u.status='ACTIVE' AND (($2='admin' AND u.role='ADMIN') OR ($2='app' AND u.role='USER'))`, hash, audience, now).Scan(&u.ID, &u.DisplayName, &u.Status, &u.Role, &u.CreatedAt, &u.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, apperror.ErrUnauthenticated
}
return u, mapError(err)
}
func (r *Postgres) RevokeSession(ctx context.Context, hash, audience string, now time.Time) error {
_, err := r.pool.Exec(ctx, `UPDATE veyra_session SET revoked_at=$3 WHERE token_hash=$1 AND audience=$2 AND revoked_at IS NULL`, hash, audience, now)
return mapError(err)
}
func (r *Postgres) ListUsers(ctx context.Context, limit int) ([]domain.User, error) {
rows, err := r.pool.Query(ctx, `SELECT id,display_name,status,role,created_at,updated_at FROM veyra_user WHERE role='USER' ORDER BY created_at DESC,id DESC LIMIT $1`, limit)
if err != nil {
return nil, apperror.ErrUnavailable
}
defer rows.Close()
users := make([]domain.User, 0)
for rows.Next() {
var u domain.User
if err := rows.Scan(&u.ID, &u.DisplayName, &u.Status, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, apperror.ErrUnavailable
}
users = append(users, u)
}
return users, mapError(rows.Err())
}
func mapError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, pgx.ErrNoRows) {
return apperror.ErrNotFound
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return apperror.ErrConflict
}
return apperror.ErrUnavailable
}
@@ -0,0 +1,144 @@
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"regexp"
"strings"
"time"
"unicode/utf8"
"veyra/services/api/internal/platform/apperror"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"veyra/services/api/internal/identity/domain"
)
type Repository interface {
CreateUser(context.Context, domain.User, domain.Credential) (domain.User, error)
FindCredential(context.Context, string) (domain.User, domain.Credential, error)
CreateSession(context.Context, domain.Session) error
FindSessionUser(context.Context, string, string, time.Time) (domain.User, error)
RevokeSession(context.Context, string, string, time.Time) error
ListUsers(context.Context, int) ([]domain.User, error)
}
type AuthService struct {
repo Repository
now func() time.Time
dummyHash []byte
}
func NewAuthService(repo Repository) (*AuthService, error) {
hash, err := bcrypt.GenerateFromPassword([]byte("invalid-user-timing-placeholder"), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("initialize password verifier: %w", err)
}
return &AuthService{repo: repo, now: time.Now, dummyHash: hash}, nil
}
var loginPattern = regexp.MustCompile(`^[a-z0-9_]{3,64}$`)
// ProvisionUser is called by a local CLI. Public registration cannot choose ADMIN.
func (s *AuthService) ProvisionUser(ctx context.Context, login, password, name, role string) (domain.User, error) {
login = strings.ToLower(strings.TrimSpace(login))
name = strings.TrimSpace(name)
if !loginPattern.MatchString(login) || len(password) < 12 || len(password) > 72 || utf8.RuneCountInString(name) > 80 || name == "" || (role != "USER" && role != "ADMIN") {
return domain.User{}, apperror.ErrInvalidInput
}
if s.repo == nil {
return domain.User{}, apperror.ErrUnavailable
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return domain.User{}, fmt.Errorf("hash password: %w", err)
}
user := domain.User{ID: uuid.NewString(), DisplayName: name, Status: "ACTIVE", Role: role}
credential := domain.Credential{ID: uuid.NewString(), UserID: user.ID, LoginName: login, PasswordHash: string(hash)}
return s.repo.CreateUser(ctx, user, credential)
}
type LoginResult struct {
User domain.User
Token string
ExpiresAt time.Time
}
func (s *AuthService) Login(ctx context.Context, login, password, audience string) (LoginResult, error) {
if audience != "admin" && audience != "app" {
return LoginResult{}, apperror.ErrInvalidInput
}
if s.repo == nil {
return LoginResult{}, apperror.ErrUnavailable
}
login = strings.ToLower(strings.TrimSpace(login))
if !loginPattern.MatchString(login) || len(password) > 72 || password == "" {
return LoginResult{}, apperror.ErrUnauthenticated
}
user, credential, err := s.repo.FindCredential(ctx, login)
if err != nil {
if errors.Is(err, apperror.ErrNotFound) {
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password))
return LoginResult{}, apperror.ErrUnauthenticated
}
return LoginResult{}, err
}
if bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(password)) != nil || user.Status != "ACTIVE" || (audience == "admin" && user.Role != "ADMIN") || (audience == "app" && user.Role != "USER") {
return LoginResult{}, apperror.ErrUnauthenticated
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return LoginResult{}, fmt.Errorf("generate session token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(raw)
expires := s.now().UTC().Add(8 * time.Hour)
session := domain.Session{ID: uuid.NewString(), UserID: user.ID, TokenHash: TokenHash(token), Audience: audience, ExpiresAt: expires}
if err := s.repo.CreateSession(ctx, session); err != nil {
return LoginResult{}, err
}
return LoginResult{User: user, Token: token, ExpiresAt: expires}, nil
}
func (s *AuthService) Authenticate(ctx context.Context, token, audience string) (domain.User, error) {
if s.repo == nil {
return domain.User{}, apperror.ErrUnavailable
}
if len(token) != 43 {
return domain.User{}, apperror.ErrUnauthenticated
}
if audience != "admin" && audience != "app" {
return domain.User{}, apperror.ErrUnauthenticated
}
user, err := s.repo.FindSessionUser(ctx, TokenHash(token), audience, s.now().UTC())
if err != nil {
return domain.User{}, err
}
if user.Status != "ACTIVE" || audience == "admin" && user.Role != "ADMIN" || audience == "app" && user.Role != "USER" {
return domain.User{}, apperror.ErrUnauthenticated
}
return user, nil
}
func (s *AuthService) Logout(ctx context.Context, token, audience string) error {
if s.repo == nil {
return apperror.ErrUnavailable
}
return s.repo.RevokeSession(ctx, TokenHash(token), audience, s.now().UTC())
}
func (s *AuthService) ListUsers(ctx context.Context) ([]domain.User, error) {
if s.repo == nil {
return nil, apperror.ErrUnavailable
}
return s.repo.ListUsers(ctx, 100)
}
func TokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,92 @@
package service
import (
"context"
"errors"
"testing"
"time"
"veyra/services/api/internal/identity/domain"
"veyra/services/api/internal/platform/apperror"
)
type testRepository struct {
user domain.User
credential domain.Credential
session domain.Session
createdSessions int
}
func (r *testRepository) CreateUser(_ context.Context, u domain.User, c domain.Credential) (domain.User, error) {
r.user = u
r.credential = c
return u, nil
}
func (r *testRepository) FindCredential(_ context.Context, login string) (domain.User, domain.Credential, error) {
if login != r.credential.LoginName {
return domain.User{}, domain.Credential{}, apperror.ErrNotFound
}
return r.user, r.credential, nil
}
func (r *testRepository) CreateSession(_ context.Context, s domain.Session) error {
r.session = s
r.createdSessions++
return nil
}
func (r *testRepository) FindSessionUser(_ context.Context, _, _ string, _ time.Time) (domain.User, error) {
return r.user, nil
}
func (r *testRepository) RevokeSession(_ context.Context, _, _ string, _ time.Time) error { return nil }
func (r *testRepository) ListUsers(context.Context, int) ([]domain.User, error) {
return []domain.User{r.user}, nil
}
func TestPasswordAndSessionSecretsAreHashed(t *testing.T) {
ctx := context.Background()
repo := &testRepository{}
auth, err := NewAuthService(repo)
if err != nil {
t.Fatal(err)
}
password := "a-development-password"
if _, err := auth.ProvisionUser(ctx, " ADMIN ", password, "Admin", "ADMIN"); err != nil {
t.Fatal(err)
}
if repo.credential.PasswordHash == password || repo.credential.LoginName != "admin" {
t.Fatal("credential handling failed")
}
result, err := auth.Login(ctx, "admin", password, "admin")
if err != nil {
t.Fatal(err)
}
if len(result.Token) != 43 || repo.session.TokenHash == result.Token || repo.session.TokenHash != TokenHash(result.Token) {
t.Fatal("raw session secret persisted")
}
if _, err := auth.Login(ctx, "admin", "incorrect-password", "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("wrong password accepted")
}
if _, err := auth.Login(ctx, "admin", password, "app"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("admin credential used for app login")
}
if _, err := auth.Login(ctx, "missing", password, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("missing identity was disclosed")
}
if repo.createdSessions != 1 {
t.Fatal("failed login created a session")
}
repo.user.Status = "DISABLED"
if _, err := auth.Authenticate(ctx, result.Token, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("disabled user remained authenticated")
}
}
func TestUserCannotAuthenticateAsAdmin(t *testing.T) {
repo := &testRepository{user: domain.User{Status: "ACTIVE", Role: "USER"}}
auth, err := NewAuthService(repo)
if err != nil {
t.Fatal(err)
}
if _, err := auth.Authenticate(context.Background(), "1234567890123456789012345678901234567890123", "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("user role passed admin authorization")
}
}