From f5935e9f52bfac1ea08039c23bb9a4a0db8f88ad Mon Sep 17 00:00:00 2001 From: Adam French Date: Sun, 30 Nov 2025 01:40:06 +0000 Subject: [PATCH] adding jwt authentication --- backend/handlers/handle_post.go | 98 ++++++++++++++++++++++++++-- backend/handlers/handle_token.go | 7 -- backend/handlers/handle_user.go | 107 ++++++++++++++++++++++++------- backend/main.go | 22 +++++-- backend/models/post.go | 5 +- backend/services/auth.go | 36 +++++++---- 6 files changed, 216 insertions(+), 59 deletions(-) delete mode 100644 backend/handlers/handle_token.go diff --git a/backend/handlers/handle_post.go b/backend/handlers/handle_post.go index 123b6a2..d42eeb7 100644 --- a/backend/handlers/handle_post.go +++ b/backend/handlers/handle_post.go @@ -5,12 +5,12 @@ import ( "adam-french.co.uk/backend/models" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" ) type CreatePostInput struct { Title string `json:"title" binding:"required"` Content string `json:"content" binding:"required"` - Author string `json:"author" binding:"required"` } func (store *Store) GetPosts(ctx *gin.Context) { @@ -19,7 +19,18 @@ func (store *Store) GetPosts(ctx *gin.Context) { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - ctx.JSON(http.StatusOK, gin.H{"data": models.Post{}}) + ctx.JSON(http.StatusOK, gin.H{"data": posts}) +} + +func (store *Store) GetPost(ctx *gin.Context) { + postID := ctx.Param("id") + var post models.Post + if err := store.DB.First(&post, postID).Error; err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"data": post}) } func (store *Store) CreatePost(ctx *gin.Context) { @@ -29,21 +40,61 @@ func (store *Store) CreatePost(ctx *gin.Context) { return } + claimsVal, ok := ctx.Get("userClaims") + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"}) + return + } + + claims, ok := claimsVal.(*jwt.MapClaims) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"}) + return + } + + userID, ok := (*claims)["id"].(uint) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"}) + return + } + // Create post - post := models.Post{Title: input.Title, Content: input.Content, Author: input.Author} + post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID} store.DB.Create(&post) - ctx.JSON(http.StatusOK, gin.H{"data": post}) + ctx.JSON(http.StatusCreated, gin.H{"data": post}) } func (store *Store) UpdatePost(ctx *gin.Context) { - id := ctx.Param("id") + postID := ctx.Param("id") var post models.Post - if err := store.DB.First(&post, id).Error; err != nil { + if err := store.DB.First(&post, postID).Error; err != nil { ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } + claimsVal, ok := ctx.Get("userClaims") + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"}) + return + } + + claims, ok := claimsVal.(*jwt.MapClaims) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"}) + return + } + + userID, ok := (*claims)["id"].(uint) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"}) + return + } + + if !(userID == post.AuthorID) { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user and post author id missmatch"}) + } + var input CreatePostInput if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -52,8 +103,41 @@ func (store *Store) UpdatePost(ctx *gin.Context) { post.Title = input.Title post.Content = input.Content - post.Author = input.Author store.DB.Save(&post) ctx.JSON(http.StatusOK, gin.H{"data": post}) } + +func (store *Store) DeletePost(ctx *gin.Context) { + postID := ctx.Param("id") + var post models.Post + if err := store.DB.First(&post, postID).Error; err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + claimsVal, ok := ctx.Get("userClaims") + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"}) + return + } + + claims, ok := claimsVal.(*jwt.MapClaims) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"}) + return + } + + userID, ok := (*claims)["id"].(uint) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"}) + return + } + + if !(userID == post.AuthorID) { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user and post author id missmatch"}) + } + + store.DB.Delete(&post) + ctx.JSON(http.StatusOK, gin.H{"data": post}) +} diff --git a/backend/handlers/handle_token.go b/backend/handlers/handle_token.go deleted file mode 100644 index 1c51dec..0000000 --- a/backend/handlers/handle_token.go +++ /dev/null @@ -1,7 +0,0 @@ -package handlers - -import "github.com/gin-gonic/gin" - -func (store *Store) RefreshToken(ctx *gin.Context) { - -} diff --git a/backend/handlers/handle_user.go b/backend/handlers/handle_user.go index 1f06271..ce865ef 100644 --- a/backend/handlers/handle_user.go +++ b/backend/handlers/handle_user.go @@ -5,6 +5,7 @@ import ( "adam-french.co.uk/backend/models" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" ) @@ -30,35 +31,41 @@ func (store *Store) CreateUser(ctx *gin.Context) { store.DB.Create(&user) // Generate JWT token + tokens, err := store.Auth.GenerateJWT(&user) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.SetCookie( + "access_token", + tokens.AccessToken, + int(store.Auth.Config.AccessTokenLifetime.Seconds()), + store.Auth.Config.Endpoint, + store.Auth.Config.Domain, + true, true, + ) + ctx.SetCookie( + "refresh_token", + tokens.RefreshToken, + int(store.Auth.Config.RefreshTokenLifetime.Seconds()), + store.Auth.Config.Endpoint, + store.Auth.Config.Domain, + true, true, + ) ctx.JSON(http.StatusOK, gin.H{"data": user}) } -func (store *Store) LoginUser(ctx *gin.Context) { - var input UserCredentials - if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - user := models.User{Username: input.Username} - if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil { +func (store *Store) GetUser(ctx *gin.Context) { + userID := ctx.Param("id") + var user models.User + if err := store.DB.First(&user, userID).Error; err != nil { ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } - if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - - // Generate JWT token - - ctx.JSON(http.StatusAccepted, gin.H{"data": user}) -} - -func (store *Store) GetUser(ctx *gin.Context) { - + ctx.JSON(http.StatusOK, gin.H{"data": user}) } func (store *Store) GetUsers(ctx *gin.Context) { @@ -67,9 +74,63 @@ func (store *Store) GetUsers(ctx *gin.Context) { ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - ctx.JSON(http.StatusOK, gin.H{"data": models.Post{}}) + ctx.JSON(http.StatusOK, gin.H{"data": users}) } -func (store *Store) UpdateUser(c *gin.Context) { +func (store *Store) UpdateUser(ctx *gin.Context) { + claimsVal, ok := ctx.Get("userClaims") + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"}) + return + } + + claims, ok := claimsVal.(*jwt.MapClaims) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"}) + return + } + + userID, ok := (*claims)["id"].(uint) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"}) + return + } + + var user models.User + if err := store.DB.First(&user, userID).Error; err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "will be implemented"}) +} + +func (store *Store) DeleteUser(ctx *gin.Context) { + claimsVal, ok := ctx.Get("userClaims") + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"}) + return + } + + claims, ok := claimsVal.(*jwt.MapClaims) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"}) + return + } + + userID, ok := (*claims)["id"].(uint) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"}) + return + } + + var user models.User + if err := store.DB.First(&user, userID).Error; err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + store.DB.Delete(&user) + ctx.JSON(http.StatusOK, gin.H{"data": user}) } diff --git a/backend/main.go b/backend/main.go index c29ad17..3f2fadd 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "os" + "time" "github.com/gin-gonic/gin" @@ -31,25 +32,34 @@ func main() { spotifyAuth, client := services.InitSpotifyAuth(&spotifyConfig) authSecret := os.Getenv("BACKEND_SECRET") - authConfig := services.AuthConfig{Secret: []byte(authSecret)} + domainName := os.Getenv("DOMAIN") + backendEndpoint := os.Getenv("BACKEND_ENDPOINT") + accessTokenLifetime := 24 * time.Hour + refreshTokenLifetime := 365 * 24 * time.Hour + authConfig := services.AuthConfig{Secret: []byte(authSecret), Domain: domainName, RefreshTokenLifetime: refreshTokenLifetime, AccessTokenLifetime: accessTokenLifetime, Endpoint: backendEndpoint} auth := services.InitAuth(&authConfig) store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: client, Auth: auth} r := gin.Default() + protected := r.Group("/", store.AuthMiddlewear) r.GET("/posts", store.GetPosts) - r.POST("/posts", store.CreatePost) - r.PUT("/posts/:id", store.UpdatePost) + protected.POST("/posts", store.CreatePost) + r.GET("/posts/:id", store.GetPost) + protected.PUT("/posts/:id", store.UpdatePost) + protected.DELETE("/posts/:id", store.DeletePost) r.GET("/user/:id", store.GetUser) + protected.PUT("/user/:id", store.UpdateUser) + protected.DELETE("/user/:id", store.DeleteUser) r.GET("/user", store.GetUsers) r.POST("/user", store.CreateUser) - r.PUT("/user", store.UpdateUser) - r.POST("/refresh", store.RefreshToken) + r.POST("/auth/login", store.Login) + r.POST("/auth/refresh", store.RefreshToken) - r.GET("/callback", store.CompleteSpotifyAuth) + r.GET("/spotify/callback", store.CompleteSpotifyAuth) r.GET("/spotify/listening", store.ListeningTo) r.GET("/spotify/recent", store.RecentlyPlayed) // r.POST("/spotify", store.SendSong) diff --git a/backend/models/post.go b/backend/models/post.go index 2681362..fad427e 100644 --- a/backend/models/post.go +++ b/backend/models/post.go @@ -5,6 +5,7 @@ import "gorm.io/gorm" type Post struct { gorm.Model // includes ID, CreatedAt, UpdatedAt, DeletedAt Title string `gorm:"not null"` - Content string `gorm:"type:text; not null"` - Author string `gorm:"not null"` + AuthorID uint + Author User `gorm:"foreignKey:AuthorID"` + Content string } diff --git a/backend/services/auth.go b/backend/services/auth.go index fc17f6c..c3a411a 100644 --- a/backend/services/auth.go +++ b/backend/services/auth.go @@ -1,6 +1,7 @@ package services import ( + "errors" "time" "adam-french.co.uk/backend/models" @@ -8,11 +9,15 @@ import ( ) type Auth struct { - config *AuthConfig + Config *AuthConfig } type AuthConfig struct { - Secret []byte + Secret []byte + Domain string + AccessTokenLifetime time.Duration + RefreshTokenLifetime time.Duration + Endpoint string } type Tokens struct { @@ -21,7 +26,7 @@ type Tokens struct { } func InitAuth(config *AuthConfig) *Auth { - auth := Auth{config: config} + auth := Auth{Config: config} return &auth } @@ -31,20 +36,20 @@ func (auth *Auth) GenerateJWT(user *models.User) (*Tokens, error) { "username": user.Username, "id": user.ID, "admin": user.Admin, - "exp": time.Now().AddDate(0, 0, 1).Unix(), + "exp": time.Now().Add(auth.Config.AccessTokenLifetime).Unix(), }) refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "id": user.ID, - "exp": time.Now().AddDate(1, 0, 0).Unix(), + "exp": time.Now().Add(auth.Config.RefreshTokenLifetime).Unix(), }) - accessTokenString, err := accessToken.SignedString(auth.config.Secret) + accessTokenString, err := accessToken.SignedString(auth.Config.Secret) if err != nil { return nil, err } - refreshTokenString, err := refreshToken.SignedString(auth.config.Secret) + refreshTokenString, err := refreshToken.SignedString(auth.Config.Secret) if err != nil { return nil, err } @@ -52,17 +57,20 @@ func (auth *Auth) GenerateJWT(user *models.User) (*Tokens, error) { return &Tokens{AccessToken: accessTokenString, RefreshToken: refreshTokenString}, nil } -func (auth *Auth) VerifyJWT(tokens Tokens) (*jwt.MapClaims, error) { - token, err := jwt.Parse(tokens.AccessToken, func(token *jwt.Token) (any, error) { - return auth.config.Secret, nil - }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) +func (auth *Auth) keyFunc(_ *jwt.Token) (any, error) { + return auth.Config.Secret, nil +} + +func (auth *Auth) VerifyJWT(tokenStr string) (*jwt.MapClaims, error) { + token, err := jwt.Parse(tokenStr, auth.keyFunc, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) if err != nil { return nil, err } - if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { - return &claims, nil + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.New("Invalid token claims type") } - return nil, err + return &claims, nil }