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 } 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.SenlinAgentUser, 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.SenlinAgentUser{ 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.SenlinAgentUser 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 } 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)) }