Skip to content

Commit c7feecf

Browse files
committed
feat: add authentication domain
1 parent b2e7506 commit c7feecf

File tree

6 files changed

+1090
-0
lines changed

6 files changed

+1090
-0
lines changed

internal/auth/jwt.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package auth
2+
3+
import (
4+
"crypto/rand"
5+
"crypto/sha256"
6+
"encoding/base64"
7+
"encoding/hex"
8+
"errors"
9+
"fmt"
10+
"time"
11+
12+
"github.com/golang-jwt/jwt/v5"
13+
"golang.org/x/crypto/bcrypt"
14+
)
15+
16+
var (
17+
// ErrInvalidToken is returned when a token is invalid.
18+
ErrInvalidToken = errors.New("invalid token")
19+
// ErrExpiredToken is returned when a token has expired.
20+
ErrExpiredToken = errors.New("token has expired")
21+
)
22+
23+
// Claims represents JWT claims.
24+
type Claims struct {
25+
UserID string `json:"user_id"`
26+
Email string `json:"email"`
27+
jwt.RegisteredClaims
28+
}
29+
30+
// JWTService handles JWT token operations.
31+
type JWTService struct {
32+
secretKey []byte
33+
accessTokenTTL time.Duration
34+
refreshTokenTTL time.Duration
35+
}
36+
37+
// NewJWTService creates a new JWT service.
38+
func NewJWTService(secretKey string, accessTokenTTL, refreshTokenTTL time.Duration) *JWTService {
39+
return &JWTService{
40+
secretKey: []byte(secretKey),
41+
accessTokenTTL: accessTokenTTL,
42+
refreshTokenTTL: refreshTokenTTL,
43+
}
44+
}
45+
46+
// GenerateAccessToken generates a new access token.
47+
func (s *JWTService) GenerateAccessToken(userID, email string) (string, error) {
48+
now := time.Now()
49+
claims := &Claims{
50+
UserID: userID,
51+
Email: email,
52+
RegisteredClaims: jwt.RegisteredClaims{
53+
ExpiresAt: jwt.NewNumericDate(now.Add(s.accessTokenTTL)),
54+
IssuedAt: jwt.NewNumericDate(now),
55+
NotBefore: jwt.NewNumericDate(now),
56+
},
57+
}
58+
59+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
60+
return token.SignedString(s.secretKey)
61+
}
62+
63+
// GenerateRefreshToken generates a new refresh token (random string).
64+
func (s *JWTService) GenerateRefreshToken() (string, error) {
65+
b := make([]byte, 32)
66+
if _, err := rand.Read(b); err != nil {
67+
return "", err
68+
}
69+
return base64.URLEncoding.EncodeToString(b), nil
70+
}
71+
72+
// ValidateAccessToken validates an access token and returns the claims.
73+
func (s *JWTService) ValidateAccessToken(tokenString string) (*Claims, error) {
74+
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
75+
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
76+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
77+
}
78+
return s.secretKey, nil
79+
})
80+
81+
if err != nil {
82+
if errors.Is(err, jwt.ErrTokenExpired) {
83+
return nil, ErrExpiredToken
84+
}
85+
return nil, ErrInvalidToken
86+
}
87+
88+
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
89+
return claims, nil
90+
}
91+
92+
return nil, ErrInvalidToken
93+
}
94+
95+
// HashToken hashes a token for storage.
96+
func HashToken(token string) string {
97+
hash := sha256.Sum256([]byte(token))
98+
return hex.EncodeToString(hash[:])
99+
}
100+
101+
// HashPassword hashes a password using bcrypt.
102+
func HashPassword(password string) (string, error) {
103+
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
104+
if err != nil {
105+
return "", err
106+
}
107+
return string(hash), nil
108+
}
109+
110+
// CheckPassword checks if a password matches a hash.
111+
func CheckPassword(password, hash string) bool {
112+
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
113+
return err == nil
114+
}
115+
116+
// GenerateAPIKey generates a new API key.
117+
func GenerateAPIKey(prefix string) (string, error) {
118+
b := make([]byte, 32)
119+
if _, err := rand.Read(b); err != nil {
120+
return "", err
121+
}
122+
random := base64.URLEncoding.EncodeToString(b)
123+
// Remove padding and limit length
124+
random = random[:40]
125+
return fmt.Sprintf("%s%s", prefix, random), nil
126+
}
127+
128+
// HashAPIKey hashes an API key for storage.
129+
func HashAPIKey(key string) string {
130+
return HashToken(key)
131+
}
132+
133+
// GetAPIKeyPrefix extracts the prefix from an API key for display.
134+
func GetAPIKeyPrefix(key string) string {
135+
if len(key) > 12 {
136+
return key[:12] + "..."
137+
}
138+
return key
139+
}

internal/auth/jwt_test.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package auth
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestJWTService_GenerateAccessToken(t *testing.T) {
12+
service := NewJWTService("test-secret-key", 15*time.Minute, 7*24*time.Hour)
13+
14+
token, err := service.GenerateAccessToken("user-123", "test@example.com")
15+
16+
require.NoError(t, err)
17+
assert.NotEmpty(t, token)
18+
}
19+
20+
func TestJWTService_ValidateAccessToken_Valid(t *testing.T) {
21+
service := NewJWTService("test-secret-key", 15*time.Minute, 7*24*time.Hour)
22+
23+
userID := "user-123"
24+
email := "test@example.com"
25+
26+
token, err := service.GenerateAccessToken(userID, email)
27+
require.NoError(t, err)
28+
29+
claims, err := service.ValidateAccessToken(token)
30+
31+
require.NoError(t, err)
32+
assert.Equal(t, userID, claims.UserID)
33+
assert.Equal(t, email, claims.Email)
34+
assert.False(t, claims.ExpiresAt.Time.IsZero())
35+
}
36+
37+
func TestJWTService_ValidateAccessToken_Invalid(t *testing.T) {
38+
service := NewJWTService("test-secret-key", 15*time.Minute, 7*24*time.Hour)
39+
40+
invalidToken := "invalid.token.here"
41+
42+
claims, err := service.ValidateAccessToken(invalidToken)
43+
44+
assert.Error(t, err)
45+
assert.ErrorIs(t, err, ErrInvalidToken)
46+
assert.Nil(t, claims)
47+
}
48+
49+
func TestJWTService_ValidateAccessToken_WrongSecret(t *testing.T) {
50+
service1 := NewJWTService("secret1", 15*time.Minute, 7*24*time.Hour)
51+
service2 := NewJWTService("secret2", 15*time.Minute, 7*24*time.Hour)
52+
53+
token, err := service1.GenerateAccessToken("user-123", "test@example.com")
54+
require.NoError(t, err)
55+
56+
claims, err := service2.ValidateAccessToken(token)
57+
58+
assert.Error(t, err)
59+
assert.ErrorIs(t, err, ErrInvalidToken)
60+
assert.Nil(t, claims)
61+
}
62+
63+
func TestJWTService_ValidateAccessToken_Expired(t *testing.T) {
64+
// Create a service with a very short TTL
65+
service := NewJWTService("test-secret-key", 1*time.Millisecond, 7*24*time.Hour)
66+
67+
token, err := service.GenerateAccessToken("user-123", "test@example.com")
68+
require.NoError(t, err)
69+
70+
// Wait for token to expire
71+
time.Sleep(10 * time.Millisecond)
72+
73+
claims, err := service.ValidateAccessToken(token)
74+
75+
assert.Error(t, err)
76+
assert.ErrorIs(t, err, ErrExpiredToken)
77+
assert.Nil(t, claims)
78+
}
79+
80+
func TestJWTService_GenerateRefreshToken(t *testing.T) {
81+
service := NewJWTService("test-secret-key", 15*time.Minute, 7*24*time.Hour)
82+
83+
token1, err1 := service.GenerateRefreshToken()
84+
token2, err2 := service.GenerateRefreshToken()
85+
86+
require.NoError(t, err1)
87+
require.NoError(t, err2)
88+
assert.NotEmpty(t, token1)
89+
assert.NotEmpty(t, token2)
90+
// Tokens should be unique
91+
assert.NotEqual(t, token1, token2)
92+
}
93+
94+
func TestHashToken(t *testing.T) {
95+
token := "test-token-12345"
96+
97+
hash1 := HashToken(token)
98+
hash2 := HashToken(token)
99+
100+
assert.NotEmpty(t, hash1)
101+
// Same token should produce same hash
102+
assert.Equal(t, hash1, hash2)
103+
// Hash should be 64 characters (SHA256 hex)
104+
assert.Len(t, hash1, 64)
105+
// Hash should be different from original
106+
assert.NotEqual(t, token, hash1)
107+
}
108+
109+
func TestHashPassword(t *testing.T) {
110+
password := "securePassword123!"
111+
112+
hash, err := HashPassword(password)
113+
114+
require.NoError(t, err)
115+
assert.NotEmpty(t, hash)
116+
// Hash should be different from password
117+
assert.NotEqual(t, password, hash)
118+
// Hash should start with bcrypt prefix
119+
assert.Contains(t, hash, "$2a$")
120+
}
121+
122+
func TestCheckPassword_Valid(t *testing.T) {
123+
password := "securePassword123!"
124+
125+
hash, err := HashPassword(password)
126+
require.NoError(t, err)
127+
128+
isValid := CheckPassword(password, hash)
129+
130+
assert.True(t, isValid)
131+
}
132+
133+
func TestCheckPassword_Invalid(t *testing.T) {
134+
password := "securePassword123!"
135+
wrongPassword := "wrongPassword"
136+
137+
hash, err := HashPassword(password)
138+
require.NoError(t, err)
139+
140+
isValid := CheckPassword(wrongPassword, hash)
141+
142+
assert.False(t, isValid)
143+
}
144+
145+
func TestGenerateAPIKey(t *testing.T) {
146+
prefix := "mcpulse_"
147+
148+
key1, err1 := GenerateAPIKey(prefix)
149+
key2, err2 := GenerateAPIKey(prefix)
150+
151+
require.NoError(t, err1)
152+
require.NoError(t, err2)
153+
assert.NotEmpty(t, key1)
154+
assert.NotEmpty(t, key2)
155+
// Keys should be unique
156+
assert.NotEqual(t, key1, key2)
157+
// Keys should start with prefix
158+
assert.Contains(t, key1, prefix)
159+
assert.Contains(t, key2, prefix)
160+
}
161+
162+
func TestHashAPIKey(t *testing.T) {
163+
key := "mcpulse_testkey12345"
164+
165+
hash := HashAPIKey(key)
166+
167+
assert.NotEmpty(t, hash)
168+
assert.NotEqual(t, key, hash)
169+
// Hash should be 64 characters (SHA256 hex)
170+
assert.Len(t, hash, 64)
171+
}
172+
173+
func TestGetAPIKeyPrefix(t *testing.T) {
174+
t.Run("long key", func(t *testing.T) {
175+
key := "mcpulse_verylongkey12345678"
176+
177+
prefix := GetAPIKeyPrefix(key)
178+
179+
assert.Equal(t, "mcpulse_very...", prefix)
180+
assert.Len(t, prefix, 15) // 12 + "..."
181+
})
182+
183+
t.Run("short key", func(t *testing.T) {
184+
key := "short"
185+
186+
prefix := GetAPIKeyPrefix(key)
187+
188+
assert.Equal(t, "short", prefix)
189+
})
190+
}

0 commit comments

Comments
 (0)