adding jwt authentication

This commit is contained in:
2025-11-30 01:40:06 +00:00
parent cd16056966
commit f5935e9f52
6 changed files with 216 additions and 59 deletions

View File

@@ -5,12 +5,12 @@ import (
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
) )
type CreatePostInput struct { type CreatePostInput struct {
Title string `json:"title" binding:"required"` Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"` Content string `json:"content" binding:"required"`
Author string `json:"author" binding:"required"`
} }
func (store *Store) GetPosts(ctx *gin.Context) { 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()}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return 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) { func (store *Store) CreatePost(ctx *gin.Context) {
@@ -29,21 +40,61 @@ func (store *Store) CreatePost(ctx *gin.Context) {
return 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 // 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) 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) { func (store *Store) UpdatePost(ctx *gin.Context) {
id := ctx.Param("id") postID := ctx.Param("id")
var post models.Post 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()}) ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return 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 var input CreatePostInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 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.Title = input.Title
post.Content = input.Content post.Content = input.Content
post.Author = input.Author
store.DB.Save(&post) store.DB.Save(&post)
ctx.JSON(http.StatusOK, gin.H{"data": 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})
}

View File

@@ -1,7 +0,0 @@
package handlers
import "github.com/gin-gonic/gin"
func (store *Store) RefreshToken(ctx *gin.Context) {
}

View File

@@ -5,6 +5,7 @@ import (
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -30,35 +31,41 @@ func (store *Store) CreateUser(ctx *gin.Context) {
store.DB.Create(&user) store.DB.Create(&user)
// Generate JWT token // 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}) ctx.JSON(http.StatusOK, gin.H{"data": user})
} }
func (store *Store) LoginUser(ctx *gin.Context) { func (store *Store) GetUser(ctx *gin.Context) {
var input UserCredentials userID := ctx.Param("id")
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { var user models.User
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) if err := store.DB.First(&user, userID).Error; err != nil {
return
}
user := models.User{Username: input.Username}
if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return return
} }
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil { ctx.JSON(http.StatusOK, gin.H{"data": user})
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) {
} }
func (store *Store) GetUsers(ctx *gin.Context) { 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()}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return 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})
} }

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -31,25 +32,34 @@ func main() {
spotifyAuth, client := services.InitSpotifyAuth(&spotifyConfig) spotifyAuth, client := services.InitSpotifyAuth(&spotifyConfig)
authSecret := os.Getenv("BACKEND_SECRET") 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) auth := services.InitAuth(&authConfig)
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: client, Auth: auth} store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: client, Auth: auth}
r := gin.Default() r := gin.Default()
protected := r.Group("/", store.AuthMiddlewear)
r.GET("/posts", store.GetPosts) r.GET("/posts", store.GetPosts)
r.POST("/posts", store.CreatePost) protected.POST("/posts", store.CreatePost)
r.PUT("/posts/:id", store.UpdatePost) r.GET("/posts/:id", store.GetPost)
protected.PUT("/posts/:id", store.UpdatePost)
protected.DELETE("/posts/:id", store.DeletePost)
r.GET("/user/:id", store.GetUser) r.GET("/user/:id", store.GetUser)
protected.PUT("/user/:id", store.UpdateUser)
protected.DELETE("/user/:id", store.DeleteUser)
r.GET("/user", store.GetUsers) r.GET("/user", store.GetUsers)
r.POST("/user", store.CreateUser) 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/listening", store.ListeningTo)
r.GET("/spotify/recent", store.RecentlyPlayed) r.GET("/spotify/recent", store.RecentlyPlayed)
// r.POST("/spotify", store.SendSong) // r.POST("/spotify", store.SendSong)

View File

@@ -5,6 +5,7 @@ import "gorm.io/gorm"
type Post struct { type Post struct {
gorm.Model // includes ID, CreatedAt, UpdatedAt, DeletedAt gorm.Model // includes ID, CreatedAt, UpdatedAt, DeletedAt
Title string `gorm:"not null"` Title string `gorm:"not null"`
Content string `gorm:"type:text; not null"` AuthorID uint
Author string `gorm:"not null"` Author User `gorm:"foreignKey:AuthorID"`
Content string
} }

View File

@@ -1,6 +1,7 @@
package services package services
import ( import (
"errors"
"time" "time"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
@@ -8,11 +9,15 @@ import (
) )
type Auth struct { type Auth struct {
config *AuthConfig Config *AuthConfig
} }
type AuthConfig struct { type AuthConfig struct {
Secret []byte Secret []byte
Domain string
AccessTokenLifetime time.Duration
RefreshTokenLifetime time.Duration
Endpoint string
} }
type Tokens struct { type Tokens struct {
@@ -21,7 +26,7 @@ type Tokens struct {
} }
func InitAuth(config *AuthConfig) *Auth { func InitAuth(config *AuthConfig) *Auth {
auth := Auth{config: config} auth := Auth{Config: config}
return &auth return &auth
} }
@@ -31,20 +36,20 @@ func (auth *Auth) GenerateJWT(user *models.User) (*Tokens, error) {
"username": user.Username, "username": user.Username,
"id": user.ID, "id": user.ID,
"admin": user.Admin, "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{ refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"id": user.ID, "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 { if err != nil {
return nil, err return nil, err
} }
refreshTokenString, err := refreshToken.SignedString(auth.config.Secret) refreshTokenString, err := refreshToken.SignedString(auth.Config.Secret)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -52,17 +57,20 @@ func (auth *Auth) GenerateJWT(user *models.User) (*Tokens, error) {
return &Tokens{AccessToken: accessTokenString, RefreshToken: refreshTokenString}, nil return &Tokens{AccessToken: accessTokenString, RefreshToken: refreshTokenString}, nil
} }
func (auth *Auth) VerifyJWT(tokens Tokens) (*jwt.MapClaims, error) { func (auth *Auth) keyFunc(_ *jwt.Token) (any, error) {
token, err := jwt.Parse(tokens.AccessToken, func(token *jwt.Token) (any, error) { return auth.Config.Secret, nil
return auth.config.Secret, nil }
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
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 { if err != nil {
return nil, err return nil, err
} }
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { claims, ok := token.Claims.(jwt.MapClaims)
return &claims, nil if !ok {
return nil, errors.New("Invalid token claims type")
} }
return nil, err return &claims, nil
} }