package auth import ( "crypto/hmac" "crypto/sha256" "errors" "fmt" "time" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" ) // ErrInvalidCredentials indicates that the supplied username/password is wrong. var ErrInvalidCredentials = errors.New("invalid credentials") // ErrInvalidToken indicates that the JWT is invalid or expired. var ErrInvalidToken = errors.New("invalid or expired token") // TokenExpiry is the lifetime of a JWT session token. const TokenExpiry = 24 * time.Hour // jwtClaims extends jwt.RegisteredClaims with application-specific fields. type jwtClaims struct { jwt.RegisteredClaims UserID string `json:"user_id"` Username string `json:"username"` Role string `json:"role"` } // LocalAuth handles password hashing and JWT token management for local auth mode. type LocalAuth struct { jwtSecret []byte } // NewLocalAuth creates a LocalAuth deriving the JWT signing key from the encryption key // using HMAC-SHA256. func NewLocalAuth(encKey [32]byte) *LocalAuth { mac := hmac.New(sha256.New, encKey[:]) mac.Write([]byte("docker-watcher-jwt-secret")) return &LocalAuth{ jwtSecret: mac.Sum(nil), } } // HashPassword hashes a plaintext password using bcrypt. func HashPassword(password string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", fmt.Errorf("hash password: %w", err) } return string(hash), nil } // CheckPassword compares a plaintext password against a bcrypt hash. func CheckPassword(hash, password string) error { if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { return ErrInvalidCredentials } return nil } // GenerateToken creates a signed JWT for the given user claims. func (la *LocalAuth) GenerateToken(claims Claims) (SessionToken, error) { expiresAt := time.Now().Add(TokenExpiry) token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwtClaims{ RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(expiresAt), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: "docker-watcher", }, UserID: claims.UserID, Username: claims.Username, Role: claims.Role, }) signed, err := token.SignedString(la.jwtSecret) if err != nil { return SessionToken{}, fmt.Errorf("sign token: %w", err) } return SessionToken{ Token: signed, ExpiresAt: expiresAt, }, nil } // ValidateToken parses and validates a JWT, returning the embedded claims. func (la *LocalAuth) ValidateToken(tokenString string) (Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &jwtClaims{}, func(token *jwt.Token) (any, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return la.jwtSecret, nil }) if err != nil { return Claims{}, ErrInvalidToken } claims, ok := token.Claims.(*jwtClaims) if !ok || !token.Valid { return Claims{}, ErrInvalidToken } return Claims{ UserID: claims.UserID, Username: claims.Username, Role: claims.Role, }, nil }