303 lines
8.2 KiB
Go
303 lines
8.2 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/wmms/wmms/internal/model"
|
|
"github.com/wmms/wmms/internal/repository"
|
|
"github.com/wmms/wmms/pkg/database"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidCredentials = errors.New("invalid username or password")
|
|
ErrUserNotFound = errors.New("user not found")
|
|
)
|
|
|
|
type AuthService struct {
|
|
userRepo *repository.UserRepository
|
|
}
|
|
|
|
func NewAuthService() *AuthService {
|
|
return &AuthService{
|
|
userRepo: repository.NewUserRepository(),
|
|
}
|
|
}
|
|
|
|
// SimpleToken represents a simple JWT-like token
|
|
type SimpleToken struct {
|
|
SessionID string `json:"session_id,omitempty"`
|
|
UserID int64 `json:"user_id"`
|
|
Username string `json:"username"`
|
|
Roles []string `json:"roles"`
|
|
Exp int64 `json:"exp"`
|
|
}
|
|
|
|
// GenerateToken creates a simple token for the user
|
|
func (s *AuthService) GenerateToken(user *model.User) (string, error) {
|
|
nonce := make([]byte, 32)
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return "", err
|
|
}
|
|
exp := time.Now().Add(24 * time.Hour).Unix()
|
|
|
|
roleCodes := make([]string, 0, len(user.Roles))
|
|
for _, role := range user.Roles {
|
|
if role.Enabled {
|
|
roleCodes = append(roleCodes, role.Code)
|
|
}
|
|
}
|
|
token := SimpleToken{
|
|
SessionID: base64.RawURLEncoding.EncodeToString(nonce),
|
|
UserID: user.ID,
|
|
Username: user.Username,
|
|
Roles: roleCodes,
|
|
Exp: exp,
|
|
}
|
|
|
|
// Create header
|
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
|
|
|
// Create payload
|
|
payloadBytes, err := json.Marshal(token)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
|
|
|
// Create signature using HMAC-SHA256 (cryptographically secure)
|
|
signatureInput := header + "." + payload
|
|
mac := hmac.New(sha256.New, []byte("warehouse-mms-secret-key"))
|
|
mac.Write([]byte(signatureInput))
|
|
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
|
|
return header + "." + payload + "." + signature, nil
|
|
}
|
|
|
|
// ValidateToken validates a token and returns the user info
|
|
func (s *AuthService) ValidateToken(tokenString string) (*SimpleToken, error) {
|
|
parts := splitToken(tokenString)
|
|
if len(parts) != 3 {
|
|
return nil, errors.New("invalid token format")
|
|
}
|
|
|
|
header, payload, signature := parts[0], parts[1], parts[2]
|
|
|
|
// Verify signature using HMAC-SHA256 (cryptographically secure)
|
|
signatureInput := header + "." + payload
|
|
mac := hmac.New(sha256.New, []byte("warehouse-mms-secret-key"))
|
|
mac.Write([]byte(signatureInput))
|
|
expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
|
|
if !hmac.Equal([]byte(signature), []byte(expectedSig)) {
|
|
return nil, errors.New("invalid token signature")
|
|
}
|
|
|
|
// Decode payload
|
|
payloadBytes, err := base64.RawURLEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var token SimpleToken
|
|
if err := json.Unmarshal(payloadBytes, &token); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check expiration
|
|
if time.Now().Unix() >= token.Exp {
|
|
return nil, errors.New("token expired")
|
|
}
|
|
|
|
if err := ValidateSession(TokenSessionID(tokenString), token.Exp); err != nil {
|
|
return nil, err
|
|
}
|
|
return &token, nil
|
|
}
|
|
|
|
// The single-server session cache is restored from revoked_tokens on startup.
|
|
// Keep only a fingerprint, never a raw bearer token, and expire entries with JWTs.
|
|
var revokedSessions = struct {
|
|
sync.Mutex
|
|
entries map[string]int64
|
|
}{entries: make(map[string]int64)}
|
|
|
|
func TokenSessionID(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return base64.RawURLEncoding.EncodeToString(sum[:])
|
|
}
|
|
|
|
func ValidateSession(id string, expiresAt int64) error {
|
|
if id == "" || time.Now().Unix() >= expiresAt {
|
|
return errors.New("session expired")
|
|
}
|
|
revokedSessions.Lock()
|
|
defer revokedSessions.Unlock()
|
|
if _, revoked := revokedSessions.entries[id]; revoked {
|
|
return errors.New("session revoked")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) RevokeSession(value string) error {
|
|
token, err := s.ValidateToken(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := database.DB.Exec(`INSERT INTO revoked_tokens (jti, user_id, expires_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE expires_at=VALUES(expires_at)`, TokenSessionID(value), token.UserID, time.Unix(token.Exp, 0)); err != nil {
|
|
return err
|
|
}
|
|
revokedSessions.Lock()
|
|
defer revokedSessions.Unlock()
|
|
for id, expires := range revokedSessions.entries {
|
|
if expires <= time.Now().Unix() {
|
|
delete(revokedSessions.entries, id)
|
|
}
|
|
}
|
|
revokedSessions.entries[TokenSessionID(value)] = token.Exp
|
|
return nil
|
|
}
|
|
|
|
// LoadRevokedSessions runs before serving requests, so restarting the process
|
|
// cannot reactivate a logged-out token. Tickets remain local to this server.
|
|
func LoadRevokedSessions() error {
|
|
// Bind the cutoff through the same driver location as the stored DATETIME.
|
|
// MySQL NOW() may use a different session timezone and omit live revocations.
|
|
rows, err := database.DB.Query(`SELECT jti, expires_at FROM revoked_tokens WHERE expires_at > ?`, time.Now())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
entries := make(map[string]int64)
|
|
for rows.Next() {
|
|
var id string
|
|
var expiry time.Time
|
|
if err := rows.Scan(&id, &expiry); err != nil {
|
|
return err
|
|
}
|
|
entries[id] = expiry.Unix()
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
revokedSessions.Lock()
|
|
revokedSessions.entries = entries
|
|
revokedSessions.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func splitToken(token string) []string {
|
|
var parts []string
|
|
start := 0
|
|
for i := 0; i < len(token); i++ {
|
|
if token[i] == '.' {
|
|
parts = append(parts, token[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
parts = append(parts, token[start:])
|
|
return parts
|
|
}
|
|
|
|
// Login authenticates a user and returns a token
|
|
func (s *AuthService) Login(username, password string) (*model.LoginResponse, error) {
|
|
user, err := s.userRepo.GetByUsername(username)
|
|
if err != nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
|
|
// Verify password
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
|
|
// Generate token
|
|
token, err := s.GenerateToken(user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &model.LoginResponse{
|
|
Token: token,
|
|
User: *user,
|
|
}, nil
|
|
}
|
|
|
|
// GetUserByID returns a user by ID
|
|
func (s *AuthService) GetUserByID(id int64) (*model.User, error) {
|
|
return s.userRepo.GetByID(id)
|
|
}
|
|
|
|
// ListUsers returns all users with pagination
|
|
func (s *AuthService) ListUsers(search string, limit, offset int) ([]model.User, int64, error) {
|
|
return s.userRepo.List(search, limit, offset)
|
|
}
|
|
|
|
// CreateUser creates a new user
|
|
func (s *AuthService) CreateUser(username, password, description string, roleIDs []int64, organization ...model.UserOrganizationInput) (*model.User, error) {
|
|
// Hash password
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user := &model.User{
|
|
Username: username,
|
|
Password: string(hashedPassword),
|
|
Description: description,
|
|
}
|
|
|
|
// Service callers always validate company access. Repository calls without
|
|
// input are reserved for the legacy bootstrap users created before migration.
|
|
input := model.UserOrganizationInput{}
|
|
if len(organization) > 0 {
|
|
input = organization[0]
|
|
}
|
|
if err := s.userRepo.Create(user, roleIDs, input); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.userRepo.GetByID(user.ID)
|
|
}
|
|
|
|
// UpdateUser updates a user (admin function)
|
|
func (s *AuthService) UpdateUser(id int64, description string, roleIDs []int64, newPassword string, organization ...model.UserOrganizationInput) error {
|
|
user, err := s.userRepo.GetByID(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
user.Description = description
|
|
|
|
if newPassword != "" {
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user.Password = string(hashedPassword)
|
|
return s.userRepo.UpdateWithPassword(user, roleIDs, organization...)
|
|
}
|
|
|
|
return s.userRepo.Update(user, roleIDs, organization...)
|
|
}
|
|
|
|
// DeleteUser deletes a user
|
|
func (s *AuthService) DeleteUser(id int64) error {
|
|
return s.userRepo.Delete(id)
|
|
}
|
|
|
|
// HashPassword hashes a password using bcrypt
|
|
func HashPassword(password string) (string, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(hash), nil
|
|
}
|