diff --git a/go.mod b/go.mod index 487420f..fc95553 100644 --- a/go.mod +++ b/go.mod @@ -8,3 +8,5 @@ require ( github.com/lib/pq v1.10.9 golang.org/x/crypto v0.36.0 ) + +require github.com/golang-jwt/jwt/v5 v5.2.1 // indirect diff --git a/go.sum b/go.sum index b4e2b34..85359cb 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index eb1cc08..dd22e51 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,7 +1,21 @@ package auth import ( + "errors" + "fmt" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" + "net/http" + "strings" + "time" +) + +type TokenType string + +const ( + // TokenTypeAccess - + TokenTypeAccess TokenType = "chirpy-access" ) func HashPassword(password string) (string, error) { @@ -12,3 +26,61 @@ func HashPassword(password string) (string, error) { func CheckPasswordHash(password, hash string) error { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) } + +// MakeJWT - +func MakeJWT( + userID uuid.UUID, + tokenSecret string, + expiresIn time.Duration, +) (string, error) { + signingKey := []byte(tokenSecret) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Issuer: string(TokenTypeAccess), + IssuedAt: jwt.NewNumericDate(time.Now().UTC()), + ExpiresAt: jwt.NewNumericDate(time.Now().UTC().Add(expiresIn)), + Subject: userID.String(), + }) + return token.SignedString(signingKey) +} + +// ValidateJWT - +func ValidateJWT(tokenString, tokenSecret string) (uuid.UUID, error) { + claimsStruct := jwt.RegisteredClaims{} + token, err := jwt.ParseWithClaims( + tokenString, + &claimsStruct, + func(token *jwt.Token) (interface{}, error) { return []byte(tokenSecret), nil }, + ) + if err != nil { + return uuid.Nil, err + } + + userIDString, err := token.Claims.GetSubject() + if err != nil { + return uuid.Nil, err + } + + issuer, err := token.Claims.GetIssuer() + if err != nil { + return uuid.Nil, err + } + if issuer != string(TokenTypeAccess) { + return uuid.Nil, errors.New("invalid issuer") + } + + id, err := uuid.Parse(userIDString) + if err != nil { + return uuid.Nil, fmt.Errorf("invalid user ID: %w", err) + } + return id, nil +} + +func GetBearerToken(headers http.Header) (string, error) { + auth := headers.Get("Authorization") + auth = strings.TrimPrefix(auth, "Bearer ") + if auth == "" { + return "", errors.New("missing Authorization header") + } + + return auth, nil +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..305d17a --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,110 @@ +package auth + +import ( + "github.com/google/uuid" + "testing" + "time" +) + +func TestCheckPasswordHash(t *testing.T) { + // First, we need to create some hashed passwords for testing + password1 := "correctPassword123!" + password2 := "anotherPassword456!" + hash1, _ := HashPassword(password1) + hash2, _ := HashPassword(password2) + + tests := []struct { + name string + password string + hash string + wantErr bool + }{ + { + name: "Correct password", + password: password1, + hash: hash1, + wantErr: false, + }, + { + name: "Incorrect password", + password: "wrongPassword", + hash: hash1, + wantErr: true, + }, + { + name: "Password doesn't match different hash", + password: password1, + hash: hash2, + wantErr: true, + }, + { + name: "Empty password", + password: "", + hash: hash1, + wantErr: true, + }, + { + name: "Invalid hash", + password: password1, + hash: "invalidhash", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CheckPasswordHash(tt.password, tt.hash) + if (err != nil) != tt.wantErr { + t.Errorf("CheckPasswordHash() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestValidateJWT(t *testing.T) { + userID := uuid.New() + validToken, _ := MakeJWT(userID, "secret", time.Hour) + + tests := []struct { + name string + tokenString string + tokenSecret string + wantUserID uuid.UUID + wantErr bool + }{ + { + name: "Valid token", + tokenString: validToken, + tokenSecret: "secret", + wantUserID: userID, + wantErr: false, + }, + { + name: "Invalid token", + tokenString: "invalid.token.string", + tokenSecret: "secret", + wantUserID: uuid.Nil, + wantErr: true, + }, + { + name: "Wrong secret", + tokenString: validToken, + tokenSecret: "wrong_secret", + wantUserID: uuid.Nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotUserID, err := ValidateJWT(tt.tokenString, tt.tokenSecret) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateJWT() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotUserID != tt.wantUserID { + t.Errorf("ValidateJWT() gotUserID = %v, want %v", gotUserID, tt.wantUserID) + } + }) + } +} diff --git a/internal/dataMaps/maps.go b/internal/dataMaps/maps.go index e54ac8e..b3db4ae 100644 --- a/internal/dataMaps/maps.go +++ b/internal/dataMaps/maps.go @@ -19,3 +19,11 @@ type User struct { UpdatedAt time.Time `json:"updated_at"` Email string `json:"email"` } + +type UserWithToken struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Email string `json:"email"` + Token string `json:"token"` +} diff --git a/main.go b/main.go index c0018ad..b016bc6 100644 --- a/main.go +++ b/main.go @@ -17,12 +17,14 @@ import ( "os" "regexp" "sync/atomic" + "time" ) type apiConfig struct { fileserverHits atomic.Int32 db database.Queries platform string + secret string } func (cfg *apiConfig) middlewareMetricsInc(next http.Handler) http.Handler { @@ -136,12 +138,18 @@ func getChirpByIDHandler(cfg *apiConfig) http.HandlerFunc { func postChirpsHandler(cfg *apiConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { type parameters struct { - Body string `json:"body"` - UserID uuid.UUID `json:"user_id"` + Body string `json:"body"` } + token, err := auth.GetBearerToken(r.Header) + if err != nil { + httpResponse.JSONHandler(w, http.StatusUnauthorized, `{"error": "Unauthorized"}`) + } + + uuid, err := auth.ValidateJWT(token, cfg.secret) + params := parameters{} - err := json.NewDecoder(r.Body).Decode(¶ms) + err = json.NewDecoder(r.Body).Decode(¶ms) if err != nil { httpResponse.JSONHandler(w, http.StatusBadRequest, `{"error": "Failed to parse request body"}`) return @@ -155,7 +163,7 @@ func postChirpsHandler(cfg *apiConfig) http.HandlerFunc { newChirp, err := cfg.db.CreateChirp(context.Background(), database.CreateChirpParams{ Body: chirp, - UserID: params.UserID, + UserID: uuid, }) if err != nil { httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "%s"}`, err.Error())) @@ -181,8 +189,9 @@ func postChirpsHandler(cfg *apiConfig) http.HandlerFunc { func loginHandler(cfg *apiConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { type parameters struct { - Email string `json:"email"` - Password string `json:"password"` + Email string `json:"email"` + Password string `json:"password"` + ExpiresInSeconds int `json:"expires_in_seconds"` } params := parameters{} @@ -204,16 +213,29 @@ func loginHandler(cfg *apiConfig) http.HandlerFunc { return } - userJSON := dataMaps.User{ + if params.ExpiresInSeconds == 0 || params.ExpiresInSeconds > 3600 { + params.ExpiresInSeconds = 3600 + } + + userJSON := dataMaps.UserWithToken{ ID: user.ID, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, Email: user.Email, } + expiresIn := time.Duration(params.ExpiresInSeconds) * time.Second + + userJSON.Token, err = auth.MakeJWT(user.ID, cfg.secret, expiresIn) + if err != nil { + httpResponse.JSONHandler(w, http.StatusBadRequest, fmt.Sprintf(`{"error": "%s"}`, err.Error())) + return + } + jsonData, err := json.Marshal(userJSON) if err != nil { httpResponse.JSONHandler(w, http.StatusInternalServerError, fmt.Sprintf(`{"error": "%s"}`, err.Error())) + return } httpResponse.JSONHandler(w, http.StatusOK, string(jsonData)) } @@ -288,6 +310,7 @@ func main() { dbURL := os.Getenv("DB_URL") apiCfg.platform = os.Getenv("PLATFORM") + apiCfg.secret = os.Getenv("SECRET") db, err := sql.Open("postgres", dbURL) if err != nil {