183 lines
5.4 KiB
Go
183 lines
5.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"senlinai-agent/backend/internal/models"
|
|
)
|
|
|
|
type Service struct {
|
|
secret string
|
|
inviteTokenTTL time.Duration
|
|
sessionTTL time.Duration
|
|
}
|
|
|
|
var (
|
|
ErrProfileNameRequired = errors.New("display name is required")
|
|
ErrCurrentPassword = errors.New("current password is incorrect")
|
|
ErrPasswordTooShort = errors.New("new password must be at least 8 characters")
|
|
)
|
|
|
|
func NewService(secret string) *Service {
|
|
return &Service{secret: secret, inviteTokenTTL: 7 * 24 * time.Hour, sessionTTL: 24 * time.Hour}
|
|
}
|
|
|
|
func (s *Service) CreateInvite(adminID uint, email string) (string, error) {
|
|
normalized := strings.ToLower(strings.TrimSpace(email))
|
|
if normalized == "" {
|
|
return "", errors.New("email is required")
|
|
}
|
|
return s.signToken(tokenPayload{
|
|
Type: "invite",
|
|
Subject: normalized,
|
|
IssuerID: adminID,
|
|
ExpiresAt: time.Now().Add(s.inviteTokenTTL).Unix(),
|
|
})
|
|
}
|
|
|
|
func (s *Service) RegisterWithInvite(token, email, displayName, password string) (*models.SaUser, error) {
|
|
payload, err := s.verifyToken(token, "invite")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if payload.Subject != strings.ToLower(strings.TrimSpace(email)) {
|
|
return nil, errors.New("invite email mismatch")
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
user := &models.SaUser{
|
|
Email: payload.Subject,
|
|
DisplayName: strings.TrimSpace(displayName),
|
|
PasswordHash: string(hash),
|
|
Role: "user",
|
|
}
|
|
return user, models.DBService.Create(user).Error
|
|
}
|
|
|
|
func (s *Service) Login(email, password string) (string, error) {
|
|
var user models.SaUser
|
|
if err := models.DBService.Where("email = ?", strings.ToLower(strings.TrimSpace(email))).First(&user).Error; err != nil {
|
|
return "", errors.New("invalid credentials")
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
|
return "", errors.New("invalid credentials")
|
|
}
|
|
return s.signToken(tokenPayload{
|
|
Type: "session",
|
|
Subject: fmt.Sprintf("%d", user.ID),
|
|
ExpiresAt: time.Now().Add(s.sessionTTL).Unix(),
|
|
})
|
|
}
|
|
|
|
func (s *Service) VerifySession(token string) (uint, error) {
|
|
payload, err := s.verifyToken(token, "session")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var userID uint
|
|
if _, err := fmt.Sscanf(payload.Subject, "%d", &userID); err != nil || userID == 0 {
|
|
return 0, errors.New("invalid session subject")
|
|
}
|
|
return userID, nil
|
|
}
|
|
|
|
// CurrentUser returns the authenticated user's profile without exposing password hashes.
|
|
func (s *Service) CurrentUser(userID uint) (*models.SaUser, error) {
|
|
var user models.SaUser
|
|
if err := models.DBService.Where("id = ?", userID).First(&user).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
// UpdateCurrentUser updates the small MVP profile surface. A password change always
|
|
// requires the current password so a stolen unlocked session cannot silently rotate it.
|
|
func (s *Service) UpdateCurrentUser(userID uint, displayName, currentPassword, newPassword string) (*models.SaUser, error) {
|
|
user, err := s.CurrentUser(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
name := strings.TrimSpace(displayName)
|
|
if name == "" {
|
|
return nil, ErrProfileNameRequired
|
|
}
|
|
updates := map[string]any{"display_name": name}
|
|
if strings.TrimSpace(newPassword) != "" {
|
|
if len(newPassword) < 8 {
|
|
return nil, ErrPasswordTooShort
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
|
|
return nil, ErrCurrentPassword
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
updates["password_hash"] = string(hash)
|
|
}
|
|
if err := models.DBService.Model(user).Updates(updates).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return s.CurrentUser(userID)
|
|
}
|
|
|
|
type tokenPayload struct {
|
|
Type string `json:"type"`
|
|
Subject string `json:"subject"`
|
|
IssuerID uint `json:"issuer_id,omitempty"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
}
|
|
|
|
func (s *Service) signToken(payload tokenPayload) (string, error) {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
encoded := base64.RawURLEncoding.EncodeToString(body)
|
|
return encoded + "." + s.sign(encoded), nil
|
|
}
|
|
|
|
func (s *Service) verifyToken(token string, expectedType string) (tokenPayload, error) {
|
|
separator := strings.LastIndex(token, ".")
|
|
if separator < 1 || separator == len(token)-1 {
|
|
return tokenPayload{}, errors.New("invalid token")
|
|
}
|
|
encoded := token[:separator]
|
|
signature := token[separator+1:]
|
|
if !hmac.Equal([]byte(signature), []byte(s.sign(encoded))) {
|
|
return tokenPayload{}, errors.New("invalid token signature")
|
|
}
|
|
body, err := base64.RawURLEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
return tokenPayload{}, errors.New("invalid token payload")
|
|
}
|
|
var payload tokenPayload
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return tokenPayload{}, errors.New("invalid token payload")
|
|
}
|
|
if payload.Type != expectedType {
|
|
return tokenPayload{}, errors.New("invalid token type")
|
|
}
|
|
if payload.ExpiresAt <= time.Now().Unix() {
|
|
return tokenPayload{}, errors.New("token expired")
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func (s *Service) sign(value string) string {
|
|
mac := hmac.New(sha256.New, []byte(s.secret))
|
|
mac.Write([]byte(value))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|