Implement JWT authentication and app passkey support
- Add JWT token generation and validation - Implement bcrypt password hashing - Create auth service with register/login/refresh/logout - Add app passkey generation and management - Implement protected routes and auth middleware - Add comprehensive tests for new functionality
This commit is contained in:
61
internal/auth/jwt.go
Normal file
61
internal/auth/jwt.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Claims represents the JWT claims for MyGO access tokens.
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
UserID string `json:"uid"`
|
||||
}
|
||||
|
||||
// GenerateAccessToken creates a signed JWT access token for a user.
|
||||
func GenerateAccessToken(userID string, secret []byte, ttl time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: uuid.NewString(),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
},
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString(secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign token: %w", err)
|
||||
}
|
||||
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
// GenerateRefreshToken creates a signed JWT refresh token.
|
||||
func GenerateRefreshToken(userID string, secret []byte, ttl time.Duration) (string, error) {
|
||||
return GenerateAccessToken(userID, secret, ttl)
|
||||
}
|
||||
|
||||
// ParseToken validates and parses a JWT token string.
|
||||
func ParseToken(tokenString string, secret []byte) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
93
internal/auth/jwt_test.go
Normal file
93
internal/auth/jwt_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateAccessToken(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
token, err := GenerateAccessToken("user-1", secret, 15*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken = %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("token is empty")
|
||||
}
|
||||
if !strings.Contains(token, ".") {
|
||||
t.Fatal("token does not look like a JWT")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenValid(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
token, err := GenerateAccessToken("user-1", secret, 15*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken = %v", err)
|
||||
}
|
||||
|
||||
claims, err := ParseToken(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseToken = %v", err)
|
||||
}
|
||||
if claims.UserID != "user-1" {
|
||||
t.Errorf("UserID = %q, want %q", claims.UserID, "user-1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenWrongSecret(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
token, err := GenerateAccessToken("user-1", secret, 15*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken = %v", err)
|
||||
}
|
||||
|
||||
_, err = ParseToken(token, []byte("wrong-secret"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong secret, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenExpired(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
token, err := GenerateAccessToken("user-1", secret, -1*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken = %v", err)
|
||||
}
|
||||
|
||||
_, err = ParseToken(token, secret)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for expired token, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenInvalidFormat(t *testing.T) {
|
||||
_, err := ParseToken("not-a-jwt", []byte("secret"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid format, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRefreshToken(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
token, err := GenerateRefreshToken("user-1", secret, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken = %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("token is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenUserIDCarried(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
token, _ := GenerateAccessToken("alice-42", secret, 15*time.Minute)
|
||||
claims, err := ParseToken(token, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseToken = %v", err)
|
||||
}
|
||||
if claims.UserID != "alice-42" {
|
||||
t.Errorf("UserID = %q, want %q", claims.UserID, "alice-42")
|
||||
}
|
||||
}
|
||||
26
internal/auth/password.go
Normal file
26
internal/auth/password.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const bcryptCost = 12
|
||||
|
||||
// HashPassword returns a bcrypt hash of the plaintext password.
|
||||
func HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// VerifyPassword compares a bcrypt hash with a plaintext password.
|
||||
func VerifyPassword(hash, password string) error {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
return fmt.Errorf("invalid password")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
internal/auth/password_test.go
Normal file
48
internal/auth/password_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashPassword(t *testing.T) {
|
||||
hash, err := HashPassword("mypassword")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword = %v", err)
|
||||
}
|
||||
if hash == "" {
|
||||
t.Fatal("hash is empty")
|
||||
}
|
||||
if hash == "mypassword" {
|
||||
t.Fatal("hash should not equal the plaintext password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPasswordCorrect(t *testing.T) {
|
||||
hash, err := HashPassword("mypassword")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword = %v", err)
|
||||
}
|
||||
|
||||
if err := VerifyPassword(hash, "mypassword"); err != nil {
|
||||
t.Fatalf("VerifyPassword = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPasswordWrong(t *testing.T) {
|
||||
hash, err := HashPassword("mypassword")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword = %v", err)
|
||||
}
|
||||
|
||||
if err := VerifyPassword(hash, "wrongpassword"); err == nil {
|
||||
t.Fatal("expected error for wrong password, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPasswordUnique(t *testing.T) {
|
||||
hash1, _ := HashPassword("mypassword")
|
||||
hash2, _ := HashPassword("mypassword")
|
||||
if hash1 == hash2 {
|
||||
t.Fatal("bcrypt should produce different hashes for the same password")
|
||||
}
|
||||
}
|
||||
30
internal/auth/token.go
Normal file
30
internal/auth/token.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const tokenPrefix = "mygo_"
|
||||
const tokenByteLen = 24
|
||||
|
||||
// GenerateToken creates a random token with the "mygo_" prefix.
|
||||
// Returns the raw token (shown to the user) and its SHA-256 hash (stored in DB).
|
||||
func GenerateToken() (raw, hash string, err error) {
|
||||
bytes := make([]byte, tokenByteLen)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", "", fmt.Errorf("generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
raw = tokenPrefix + hex.EncodeToString(bytes)
|
||||
hash = HashToken(raw)
|
||||
return raw, hash, nil
|
||||
}
|
||||
|
||||
// HashToken returns the SHA-256 hex digest of a token.
|
||||
func HashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
59
internal/auth/token_test.go
Normal file
59
internal/auth/token_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateToken(t *testing.T) {
|
||||
raw, hash, err := GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken = %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(raw, tokenPrefix) {
|
||||
t.Errorf("raw token %q does not start with %q", raw, tokenPrefix)
|
||||
}
|
||||
|
||||
expectedHash := HashToken(raw)
|
||||
if hash != expectedHash {
|
||||
t.Errorf("hash = %q, want %q", hash, expectedHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTokenUniqueness(t *testing.T) {
|
||||
raw1, _, _ := GenerateToken()
|
||||
raw2, _, _ := GenerateToken()
|
||||
|
||||
if raw1 == raw2 {
|
||||
t.Fatal("two generated tokens should not be equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTokenLength(t *testing.T) {
|
||||
raw, _, err := GenerateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken = %v", err)
|
||||
}
|
||||
|
||||
expectedLen := len(tokenPrefix) + tokenByteLen*2 // hex encodes each byte as 2 chars
|
||||
if len(raw) != expectedLen {
|
||||
t.Errorf("token length = %d, want %d", len(raw), expectedLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashTokenDeterministic(t *testing.T) {
|
||||
hash1 := HashToken("mygo_test_token")
|
||||
hash2 := HashToken("mygo_test_token")
|
||||
if hash1 != hash2 {
|
||||
t.Fatal("HashToken should be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashTokenDifferent(t *testing.T) {
|
||||
hash1 := HashToken("mygo_aaa")
|
||||
hash2 := HashToken("mygo_bbb")
|
||||
if hash1 == hash2 {
|
||||
t.Fatal("different inputs should produce different hashes")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user