init
This commit is contained in:
commit
b15b95781c
108 changed files with 14802 additions and 0 deletions
259
internal/auth/service.go
Normal file
259
internal/auth/service.go
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"update_server/internal/config"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
const sessionTokenBytes = 32
|
||||
const sessionCookiePath = "/admin"
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUnauthenticated = errors.New("unauthenticated")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
config config.Config
|
||||
logger *slog.Logger
|
||||
store *db.Store
|
||||
}
|
||||
|
||||
type SessionState struct {
|
||||
User db.User
|
||||
Session db.Session
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, logger *slog.Logger, store *db.Store) *Service {
|
||||
return &Service{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) EnsureBootstrapAdmin(ctx context.Context) error {
|
||||
hasActiveAdmin, err := s.store.Users.HasActiveAdmin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check active admin users: %w", err)
|
||||
}
|
||||
|
||||
if hasActiveAdmin {
|
||||
return nil
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(s.config.AdminEmail)
|
||||
password := s.config.AdminPassword
|
||||
if email == "" || strings.TrimSpace(password) == "" {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("no active admin user found; set ADMIN_EMAIL and ADMIN_PASSWORD to bootstrap the first admin")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if existingUser, err := s.store.Users.GetByEmail(ctx, email); err == nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("bootstrap admin skipped because the configured email already exists", "email", existingUser.Email)
|
||||
}
|
||||
|
||||
return nil
|
||||
} else if !errors.Is(err, db.ErrNotFound) {
|
||||
return fmt.Errorf("check bootstrap admin email: %w", err)
|
||||
}
|
||||
|
||||
passwordHash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash bootstrap admin password: %w", err)
|
||||
}
|
||||
|
||||
if _, err := s.store.Users.Create(ctx, db.CreateUserParams{
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
Role: db.UserRoleAdmin,
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("create bootstrap admin user: %w", err)
|
||||
}
|
||||
|
||||
if s.logger != nil {
|
||||
s.logger.Info("bootstrapped admin user", "email", email)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Authenticate(ctx context.Context, email, password, ipAddress, userAgent string) (string, *SessionState, error) {
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" || password == "" {
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
user, err := s.store.Users.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
return "", nil, fmt.Errorf("load user by email: %w", err)
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if err := ComparePassword(user.PasswordHash, password); err != nil {
|
||||
return "", nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
expiresAt := now.Add(s.config.SessionTTL)
|
||||
token, tokenHash, err := generateSessionToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
var session *db.Session
|
||||
if err := s.store.WithTx(ctx, func(tx *db.TxStore) error {
|
||||
createdSession, err := tx.Sessions.Create(ctx, db.CreateSessionParams{
|
||||
UserID: user.ID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: expiresAt,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Users.UpdateLastLoginAt(ctx, user.ID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session = createdSession
|
||||
return nil
|
||||
}); err != nil {
|
||||
return "", nil, fmt.Errorf("create authenticated session: %w", err)
|
||||
}
|
||||
|
||||
user.LastLoginAt = &now
|
||||
|
||||
return token, &SessionState{
|
||||
User: *user,
|
||||
Session: *session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadSession(ctx context.Context, token string) (*SessionState, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
record, err := s.store.Sessions.GetActiveWithUserByTokenHash(ctx, hashSessionToken(token), now)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("lookup active session: %w", err)
|
||||
}
|
||||
|
||||
if err := s.store.Sessions.Touch(ctx, record.Session.ID, now); err != nil {
|
||||
return nil, fmt.Errorf("touch active session: %w", err)
|
||||
}
|
||||
|
||||
record.Session.LastSeenAt = &now
|
||||
|
||||
return &SessionState{
|
||||
User: record.User,
|
||||
Session: record.Session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) InvalidateSession(ctx context.Context, token string) error {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.store.Sessions.InvalidateByTokenHash(ctx, hashSessionToken(token), time.Now().UTC()); err != nil && !errors.Is(err, db.ErrNotFound) {
|
||||
return fmt.Errorf("invalidate session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) SessionCookie(token string, expiresAt time.Time) *http.Cookie {
|
||||
maxAge := int(time.Until(expiresAt).Seconds())
|
||||
if maxAge < 0 {
|
||||
maxAge = 0
|
||||
}
|
||||
|
||||
return &http.Cookie{
|
||||
Name: s.config.SessionCookieName,
|
||||
Value: token,
|
||||
Path: sessionCookiePath,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.config.SecureCookies,
|
||||
Expires: expiresAt.UTC(),
|
||||
MaxAge: maxAge,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ClearSessionCookie() *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: s.config.SessionCookieName,
|
||||
Value: "",
|
||||
Path: sessionCookiePath,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.config.SecureCookies,
|
||||
Expires: time.Unix(0, 0).UTC(),
|
||||
MaxAge: -1,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SessionCookieName() string {
|
||||
return s.config.SessionCookieName
|
||||
}
|
||||
|
||||
func RoleAllowed(actualRole, requiredRole db.UserRole) bool {
|
||||
ranks := map[db.UserRole]int{
|
||||
db.UserRoleViewer: 1,
|
||||
db.UserRoleEditor: 2,
|
||||
db.UserRoleAdmin: 3,
|
||||
}
|
||||
|
||||
return ranks[actualRole] >= ranks[requiredRole] && ranks[requiredRole] > 0
|
||||
}
|
||||
|
||||
func generateSessionToken() (string, string, error) {
|
||||
bytes := make([]byte, sessionTokenBytes)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", "", fmt.Errorf("generate session token: %w", err)
|
||||
}
|
||||
|
||||
token := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
return token, hashSessionToken(token), nil
|
||||
}
|
||||
|
||||
func hashSessionToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue