package auth import ( "context" "crypto/rand" "database/sql" "encoding/hex" "errors" "fmt" "strings" "time" "golang.org/x/crypto/bcrypt" ) var ( ErrInvalidCredentials = errors.New("invalid credentials") ErrUnauthorized = errors.New("unauthorized") ) type User struct { ID string `json:"id"` Username string `json:"username"` IsAdmin bool `json:"isAdmin"` CreatedAt time.Time `json:"createdAt"` LastLoginAt time.Time `json:"lastLoginAt"` } type Session struct { Token string `json:"token"` User User `json:"user"` } type Service struct { db *sql.DB } func NewService(db *sql.DB) *Service { return &Service{db: db} } func (s *Service) Login(ctx context.Context, username, password string) (Session, error) { user, passwordHash, err := s.findUserByUsername(ctx, username) if err != nil { if errors.Is(err, sql.ErrNoRows) { return Session{}, ErrInvalidCredentials } return Session{}, fmt.Errorf("find user: %w", err) } if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil { return Session{}, ErrInvalidCredentials } token, err := newToken() if err != nil { return Session{}, fmt.Errorf("generate token: %w", err) } now := time.Now().UTC() expiresAt := now.Add(30 * 24 * time.Hour) if _, err := s.db.ExecContext( ctx, `INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`, token, user.ID, now.Format(time.RFC3339), expiresAt.Format(time.RFC3339), ); err != nil { return Session{}, fmt.Errorf("insert session: %w", err) } if _, err := s.db.ExecContext( ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, now.Format(time.RFC3339), user.ID, ); err == nil { user.LastLoginAt = now } return Session{ Token: token, User: user, }, nil } func (s *Service) CurrentUser(ctx context.Context, authorizationHeader string) (User, error) { token := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, "Bearer ")) return s.CurrentUserByToken(ctx, token) } func (s *Service) CurrentUserByToken(ctx context.Context, token string) (User, error) { if token == "" { return User{}, ErrUnauthorized } user, err := s.findUserByToken(ctx, token) if err != nil { if errors.Is(err, sql.ErrNoRows) { return User{}, ErrUnauthorized } return User{}, fmt.Errorf("find user by token: %w", err) } return user, nil } func (s *Service) findUserByUsername(ctx context.Context, username string) (User, string, error) { var user User var passwordHash string var createdAt string var lastLoginAt sql.NullString var isAdmin int err := s.db.QueryRowContext( ctx, `SELECT id, username, password_hash, is_admin, created_at, last_login_at FROM users WHERE username = ?`, username, ).Scan( &user.ID, &user.Username, &passwordHash, &isAdmin, &createdAt, &lastLoginAt, ) if err != nil { return User{}, "", err } user.IsAdmin = isAdmin == 1 user.CreatedAt = parseTime(createdAt) if lastLoginAt.Valid { user.LastLoginAt = parseTime(lastLoginAt.String) } return user, passwordHash, nil } func (s *Service) findUserByToken(ctx context.Context, token string) (User, error) { var user User var createdAt string var lastLoginAt sql.NullString var isAdmin int err := s.db.QueryRowContext( ctx, `SELECT u.id, u.username, u.is_admin, u.created_at, u.last_login_at FROM users u JOIN sessions s ON s.user_id = u.id WHERE s.token = ? AND s.expires_at > ?`, token, time.Now().UTC().Format(time.RFC3339), ).Scan( &user.ID, &user.Username, &isAdmin, &createdAt, &lastLoginAt, ) if err != nil { return User{}, err } user.IsAdmin = isAdmin == 1 user.CreatedAt = parseTime(createdAt) if lastLoginAt.Valid { user.LastLoginAt = parseTime(lastLoginAt.String) } return user, nil } func parseTime(raw string) time.Time { parsed, err := time.Parse(time.RFC3339, raw) if err != nil { return time.Time{} } return parsed } func newToken() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil }