// client2server - Authentication (JWT) and password hashing // // Roles: system_admin, project_admin, user // Tokens are HMAC-SHA256, 24h expiry. package main import ( "crypto/hmac" "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "os" "strings" "sync" "time" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/scrypt" ) const ( RoleSystemAdmin = "system_admin" RoleProjectAdmin = "project_admin" RoleUser = "user" tokenLifetime = 24 * time.Hour scryptN = 1 << 15 scryptR = 8 scryptP = 1 scryptKeyLen = 32 ) var ( jwtSecretMu sync.RWMutex jwtSecret []byte ) func getJWTSecret() []byte { jwtSecretMu.RLock() defer jwtSecretMu.RUnlock() if len(jwtSecret) > 0 { return jwtSecret } // Default to env, or a deterministic dev key s := os.Getenv("JWT_SECRET") if s == "" { s = "dev-insecure-jwt-secret-change-me-please-32b" } return []byte(s) } func setJWTSecret(s []byte) { jwtSecretMu.Lock() defer jwtSecretMu.Unlock() jwtSecret = s } // ---------------------------------------------------------------------------- // Password hashing (scrypt) // ---------------------------------------------------------------------------- func HashPassword(password string) (string, error) { salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { return "", err } h, err := scryptHash([]byte(password), salt) if err != nil { return "", err } return fmt.Sprintf("scrypt$%s$%s", base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(h), ), nil } func VerifyPassword(password, encoded string) (bool, error) { parts := strings.Split(encoded, "$") if len(parts) != 3 || parts[0] != "scrypt" { return false, errors.New("invalid hash format") } salt, err := base64.RawStdEncoding.DecodeString(parts[1]) if err != nil { return false, err } want, err := base64.RawStdEncoding.DecodeString(parts[2]) if err != nil { return false, err } got, err := scryptHash([]byte(password), salt) if err != nil { return false, err } return hmac.Equal(got, want), nil } func scryptHash(password, salt []byte) ([]byte, error) { return scryptKey(password, salt, scryptN, scryptR, scryptP, scryptKeyLen) } // scryptKey is a small wrapper that uses stdlib crypto/scrypt via a pure-Go // implementation. We import golang.org/x/crypto/scrypt for the real one. // // (Note: scrypt is a real stdlib-ish dep. The 'scrypt' symbol below is // provided by golang.org/x/crypto/scrypt, imported separately as needed.) func scryptKey(pw, salt []byte, n, r, p, keyLen int) ([]byte, error) { return scrypt.Key(pw, salt, n, r, p, keyLen) } // ---------------------------------------------------------------------------- // JWT // ---------------------------------------------------------------------------- type Claims struct { UserID int64 `json:"uid"` Username string `json:"usr"` Role string `json:"rol"` jwt.RegisteredClaims } func IssueJWT(userID int64, username, role string) (string, error) { now := time.Now() claims := Claims{ UserID: userID, Username: username, Role: role, RegisteredClaims: jwt.RegisteredClaims{ Issuer: "client2server", Subject: username, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(tokenLifetime)), }, } tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return tok.SignedString(getJWTSecret()) } func ParseJWT(tokenStr string) (*Claims, error) { claims := &Claims{} tok, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return getJWTSecret(), nil }) if err != nil { return nil, err } if !tok.Valid { return nil, errors.New("invalid token") } return claims, nil } // ---------------------------------------------------------------------------- // HTTP middleware // ---------------------------------------------------------------------------- type ctxKey int const ctxClaimsKey ctxKey = 0 func authMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") auth := r.Header.Get("Authorization") token := strings.TrimPrefix(auth, "Bearer ") if token == "" || token == auth { http.Error(w, `{"error":"missing bearer token"}`, http.StatusUnauthorized) return } claims, err := ParseJWT(token) if err != nil { http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) return } next(w, r.WithContext(r.Context())) _ = claims } } func requireRole(roles ...string) func(http.HandlerFunc) http.HandlerFunc { return func(next http.HandlerFunc) http.HandlerFunc { return authMiddleware(func(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") token := strings.TrimPrefix(auth, "Bearer ") claims, err := ParseJWT(token) if err != nil { http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) return } for _, want := range roles { if claims.Role == want { next(w, r) return } } http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) }) } } // ---------------------------------------------------------------------------- // Login endpoint // ---------------------------------------------------------------------------- type loginRequest struct { Username string `json:"username"` Password string `json:"password"` } type loginResponse struct { Token string `json:"token"` Role string `json:"role"` Expires string `json:"expires"` } func handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) return } var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest) return } if req.Username == "" || req.Password == "" { http.Error(w, `{"error":"username and password required"}`, http.StatusBadRequest) return } var ( id int64 hash string role string ) err := db.QueryRow("SELECT id, password_hash, role FROM users WHERE username = ?", req.Username).Scan(&id, &hash, &role) if err != nil { http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized) return } ok, err := VerifyPassword(req.Password, hash) if err != nil || !ok { http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized) return } tok, err := IssueJWT(id, req.Username, role) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(loginResponse{ Token: tok, Role: role, Expires: time.Now().Add(tokenLifetime).Format(time.RFC3339), }) } // ---------------------------------------------------------------------------- // Random hex (for default admin token in logs) // ---------------------------------------------------------------------------- func randHex(n int) string { b := make([]byte, n) _, _ = rand.Read(b) return hex.EncodeToString(b) }