auth.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. // client2server - Authentication (JWT) and password hashing
  2. //
  3. // Roles: system_admin, project_admin, user
  4. // Tokens are HMAC-SHA256, 24h expiry.
  5. package main
  6. import (
  7. "crypto/hmac"
  8. "crypto/rand"
  9. "encoding/base64"
  10. "encoding/hex"
  11. "encoding/json"
  12. "errors"
  13. "fmt"
  14. "net/http"
  15. "os"
  16. "strings"
  17. "sync"
  18. "time"
  19. "github.com/golang-jwt/jwt/v5"
  20. "golang.org/x/crypto/scrypt"
  21. )
  22. const (
  23. RoleSystemAdmin = "system_admin"
  24. RoleProjectAdmin = "project_admin"
  25. RoleUser = "user"
  26. tokenLifetime = 24 * time.Hour
  27. scryptN = 1 << 15
  28. scryptR = 8
  29. scryptP = 1
  30. scryptKeyLen = 32
  31. )
  32. var (
  33. jwtSecretMu sync.RWMutex
  34. jwtSecret []byte
  35. )
  36. func getJWTSecret() []byte {
  37. jwtSecretMu.RLock()
  38. defer jwtSecretMu.RUnlock()
  39. if len(jwtSecret) > 0 {
  40. return jwtSecret
  41. }
  42. // Default to env, or a deterministic dev key
  43. s := os.Getenv("JWT_SECRET")
  44. if s == "" {
  45. s = "dev-insecure-jwt-secret-change-me-please-32b"
  46. }
  47. return []byte(s)
  48. }
  49. func setJWTSecret(s []byte) {
  50. jwtSecretMu.Lock()
  51. defer jwtSecretMu.Unlock()
  52. jwtSecret = s
  53. }
  54. // ----------------------------------------------------------------------------
  55. // Password hashing (scrypt)
  56. // ----------------------------------------------------------------------------
  57. func HashPassword(password string) (string, error) {
  58. salt := make([]byte, 16)
  59. if _, err := rand.Read(salt); err != nil {
  60. return "", err
  61. }
  62. h, err := scryptHash([]byte(password), salt)
  63. if err != nil {
  64. return "", err
  65. }
  66. return fmt.Sprintf("scrypt$%s$%s",
  67. base64.RawStdEncoding.EncodeToString(salt),
  68. base64.RawStdEncoding.EncodeToString(h),
  69. ), nil
  70. }
  71. func VerifyPassword(password, encoded string) (bool, error) {
  72. parts := strings.Split(encoded, "$")
  73. if len(parts) != 3 || parts[0] != "scrypt" {
  74. return false, errors.New("invalid hash format")
  75. }
  76. salt, err := base64.RawStdEncoding.DecodeString(parts[1])
  77. if err != nil {
  78. return false, err
  79. }
  80. want, err := base64.RawStdEncoding.DecodeString(parts[2])
  81. if err != nil {
  82. return false, err
  83. }
  84. got, err := scryptHash([]byte(password), salt)
  85. if err != nil {
  86. return false, err
  87. }
  88. return hmac.Equal(got, want), nil
  89. }
  90. func scryptHash(password, salt []byte) ([]byte, error) {
  91. return scryptKey(password, salt, scryptN, scryptR, scryptP, scryptKeyLen)
  92. }
  93. // scryptKey is a small wrapper that uses stdlib crypto/scrypt via a pure-Go
  94. // implementation. We import golang.org/x/crypto/scrypt for the real one.
  95. //
  96. // (Note: scrypt is a real stdlib-ish dep. The 'scrypt' symbol below is
  97. // provided by golang.org/x/crypto/scrypt, imported separately as needed.)
  98. func scryptKey(pw, salt []byte, n, r, p, keyLen int) ([]byte, error) {
  99. return scrypt.Key(pw, salt, n, r, p, keyLen)
  100. }
  101. // ----------------------------------------------------------------------------
  102. // JWT
  103. // ----------------------------------------------------------------------------
  104. type Claims struct {
  105. UserID int64 `json:"uid"`
  106. Username string `json:"usr"`
  107. Role string `json:"rol"`
  108. jwt.RegisteredClaims
  109. }
  110. func IssueJWT(userID int64, username, role string) (string, error) {
  111. now := time.Now()
  112. claims := Claims{
  113. UserID: userID,
  114. Username: username,
  115. Role: role,
  116. RegisteredClaims: jwt.RegisteredClaims{
  117. Issuer: "client2server",
  118. Subject: username,
  119. IssuedAt: jwt.NewNumericDate(now),
  120. ExpiresAt: jwt.NewNumericDate(now.Add(tokenLifetime)),
  121. },
  122. }
  123. tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
  124. return tok.SignedString(getJWTSecret())
  125. }
  126. func ParseJWT(tokenStr string) (*Claims, error) {
  127. claims := &Claims{}
  128. tok, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
  129. if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
  130. return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
  131. }
  132. return getJWTSecret(), nil
  133. })
  134. if err != nil {
  135. return nil, err
  136. }
  137. if !tok.Valid {
  138. return nil, errors.New("invalid token")
  139. }
  140. return claims, nil
  141. }
  142. // ----------------------------------------------------------------------------
  143. // HTTP middleware
  144. // ----------------------------------------------------------------------------
  145. type ctxKey int
  146. const ctxClaimsKey ctxKey = 0
  147. func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
  148. return func(w http.ResponseWriter, r *http.Request) {
  149. w.Header().Set("Content-Type", "application/json")
  150. auth := r.Header.Get("Authorization")
  151. token := strings.TrimPrefix(auth, "Bearer ")
  152. if token == "" || token == auth {
  153. http.Error(w, `{"error":"missing bearer token"}`, http.StatusUnauthorized)
  154. return
  155. }
  156. claims, err := ParseJWT(token)
  157. if err != nil {
  158. http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
  159. return
  160. }
  161. next(w, r.WithContext(r.Context()))
  162. _ = claims
  163. }
  164. }
  165. func requireRole(roles ...string) func(http.HandlerFunc) http.HandlerFunc {
  166. return func(next http.HandlerFunc) http.HandlerFunc {
  167. return authMiddleware(func(w http.ResponseWriter, r *http.Request) {
  168. auth := r.Header.Get("Authorization")
  169. token := strings.TrimPrefix(auth, "Bearer ")
  170. claims, err := ParseJWT(token)
  171. if err != nil {
  172. http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
  173. return
  174. }
  175. for _, want := range roles {
  176. if claims.Role == want {
  177. next(w, r)
  178. return
  179. }
  180. }
  181. http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
  182. })
  183. }
  184. }
  185. // ----------------------------------------------------------------------------
  186. // Login endpoint
  187. // ----------------------------------------------------------------------------
  188. type loginRequest struct {
  189. Username string `json:"username"`
  190. Password string `json:"password"`
  191. }
  192. type loginResponse struct {
  193. Token string `json:"token"`
  194. Role string `json:"role"`
  195. Expires string `json:"expires"`
  196. }
  197. func handleLogin(w http.ResponseWriter, r *http.Request) {
  198. if r.Method != http.MethodPost {
  199. http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
  200. return
  201. }
  202. var req loginRequest
  203. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  204. http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
  205. return
  206. }
  207. if req.Username == "" || req.Password == "" {
  208. http.Error(w, `{"error":"username and password required"}`, http.StatusBadRequest)
  209. return
  210. }
  211. var (
  212. id int64
  213. hash string
  214. role string
  215. )
  216. err := db.QueryRow("SELECT id, password_hash, role FROM users WHERE username = ?", req.Username).Scan(&id, &hash, &role)
  217. if err != nil {
  218. http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
  219. return
  220. }
  221. ok, err := VerifyPassword(req.Password, hash)
  222. if err != nil || !ok {
  223. http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
  224. return
  225. }
  226. tok, err := IssueJWT(id, req.Username, role)
  227. if err != nil {
  228. http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
  229. return
  230. }
  231. w.Header().Set("Content-Type", "application/json")
  232. _ = json.NewEncoder(w).Encode(loginResponse{
  233. Token: tok,
  234. Role: role,
  235. Expires: time.Now().Add(tokenLifetime).Format(time.RFC3339),
  236. })
  237. }
  238. // ----------------------------------------------------------------------------
  239. // Random hex (for default admin token in logs)
  240. // ----------------------------------------------------------------------------
  241. func randHex(n int) string {
  242. b := make([]byte, n)
  243. _, _ = rand.Read(b)
  244. return hex.EncodeToString(b)
  245. }