implement JWT tokens, regenerate docs and sqlc

This commit is contained in:
zardzul
2026-03-14 18:46:48 +01:00
parent 131aee8638
commit d5e608feeb
18 changed files with 441 additions and 82 deletions
+6 -1
View File
@@ -1,2 +1,7 @@
# Postgres
DATABASE_URL=postgresql://user:password@host:5432/database
DATABASE_URL=postgresql://user:password@host:5432/database
# JWT
JWT_SECRET=
JWT_ISSUER=
JWT_TTL_MINUTES=
+74
View File
@@ -89,6 +89,57 @@ const docTemplate = `{
}
}
},
"/users/login": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Log in with email and password",
"parameters": [
{
"description": "Login payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.LoginRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.LoginResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/users/{id}": {
"get": {
"produces": [
@@ -170,6 +221,29 @@ const docTemplate = `{
}
}
},
"handlers.LoginRequest": {
"type": "object",
"required": [
"password",
"user_mail"
],
"properties": {
"password": {
"type": "string"
},
"user_mail": {
"type": "string"
}
}
},
"handlers.LoginResponse": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"handlers.UsernameResponse": {
"type": "object",
"properties": {
+74
View File
@@ -82,6 +82,57 @@
}
}
},
"/users/login": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Log in with email and password",
"parameters": [
{
"description": "Login payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.LoginRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.LoginResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/users/{id}": {
"get": {
"produces": [
@@ -163,6 +214,29 @@
}
}
},
"handlers.LoginRequest": {
"type": "object",
"required": [
"password",
"user_mail"
],
"properties": {
"password": {
"type": "string"
},
"user_mail": {
"type": "string"
}
}
},
"handlers.LoginResponse": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"handlers.UsernameResponse": {
"type": "object",
"properties": {
+48
View File
@@ -26,6 +26,21 @@ definitions:
error:
type: string
type: object
handlers.LoginRequest:
properties:
password:
type: string
user_mail:
type: string
required:
- password
- user_mail
type: object
handlers.LoginResponse:
properties:
token:
type: string
type: object
handlers.UsernameResponse:
properties:
user_name:
@@ -111,4 +126,37 @@ paths:
summary: Create a user
tags:
- user
/users/login:
post:
consumes:
- application/json
parameters:
- description: Login payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/handlers.LoginRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.LoginResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Log in with email and password
tags:
- user
swagger: "2.0"
+3 -2
View File
@@ -4,11 +4,14 @@ go 1.25.7
require (
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.48.0
)
require (
@@ -33,7 +36,6 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -49,7 +51,6 @@ require (
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
+2
View File
@@ -46,6 +46,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+67 -3
View File
@@ -2,7 +2,9 @@ package handlers
import (
"net/http"
"time"
db "zardzul/music-index/sqlc"
"zardzul/music-index/utils"
"zardzul/music-index/repository"
@@ -13,11 +15,19 @@ import (
)
type UserHandler struct {
repo repository.UserRepository
repo repository.UserRepository
jwtSecret string
jwtIssuer string
jwtTTL time.Duration
}
func NewUserHandler(repo repository.UserRepository) *UserHandler {
return &UserHandler{repo: repo}
func NewUserHandler(repo repository.UserRepository, jwtSecret string, jwtIssuer string, jwtTTL time.Duration) *UserHandler {
return &UserHandler{
repo: repo,
jwtSecret: jwtSecret,
jwtIssuer: jwtIssuer,
jwtTTL: jwtTTL,
}
}
type CreateUserRequest struct {
@@ -116,3 +126,57 @@ func (h *UserHandler) GetUsernameByID(c *gin.Context) {
"user_name": username,
})
}
type LoginRequest struct {
UserMail string `json:"user_mail" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type LoginResponse struct {
Token string `json:"token"`
}
// Login godoc
// @Summary Log in with email and password
// @Tags user
// @Accept json
// @Produce json
// @Param payload body LoginRequest true "Login payload"
// @Success 200 {object} LoginResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /users/login [post]
func (h *UserHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
user, err := h.repo.GetUserAuthByEmail(c.Request.Context(), req.UserMail)
if err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "invalid credentials"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "invalid credentials"})
return
}
token, err := utils.GenerateToken(
h.jwtSecret,
h.jwtIssuer,
user.ID.String(),
user.UserName,
user.UserMail,
h.jwtTTL,
)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to create token"})
return
}
c.JSON(http.StatusOK, LoginResponse{Token: token})
}
+24 -2
View File
@@ -1,6 +1,9 @@
package main
import (
"os"
"strconv"
"time"
"zardzul/music-index/database"
_ "zardzul/music-index/docs"
"zardzul/music-index/handlers"
@@ -28,13 +31,32 @@ func main() {
}
defer pool.Close()
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
panic("JWT_SECRET is required")
}
jwtIssuer := os.Getenv("JWT_ISSUER")
if jwtIssuer == "" {
jwtIssuer = "music-index-api"
}
jwtTTLMinutes := 60
if envTTL := os.Getenv("JWT_TTL_MINUTES"); envTTL != "" {
parsed, parseErr := strconv.Atoi(envTTL)
if parseErr != nil || parsed <= 0 {
panic("JWT_TTL_MINUTES must be a positive integer")
}
jwtTTLMinutes = parsed
}
userRepo := repository.NewUserRepository(queries)
userHandler := handlers.NewUserHandler(userRepo)
userHandler := handlers.NewUserHandler(userRepo, jwtSecret, jwtIssuer, time.Duration(jwtTTLMinutes)*time.Minute)
artistRepo := repository.NewArtistRepository(queries)
artistHandler := handlers.NewArtistHandler(artistRepo)
router := gin.Default()
routes.Routes(router, userHandler, artistHandler)
routes.Routes(router, userHandler, artistHandler, jwtSecret)
if routerError := router.Run(":8080"); routerError != nil {
panic(routerError)
+31
View File
@@ -0,0 +1,31 @@
package middleware
import (
"net/http"
"strings"
"zardzul/music-index/utils"
"github.com/gin-gonic/gin"
)
func JWTAuth(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing or invalid authorization header"})
return
}
claims, err := utils.ValidateToken(secret, parts[1])
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set("user_id", claims.Subject)
c.Set("user_name", claims.UserName)
c.Next()
}
}
+4 -11
View File
@@ -8,17 +8,10 @@ UPDATE users SET user_name = $2, user_mail = $3, password = $4 WHERE id = $1;
-- name: GetUsernameByID :one
SELECT user_name FROM users WHERE id = $1;
-- name: CheckUserExistsByEmail :one
SELECT id FROM users WHERE user_mail = $1;
-- name: GetUserByID :one
SELECT id, user_name, user_mail, created_at FROM users WHERE id = $1;
-- name: LoginUser :one
SELECT id, user_name, user_mail FROM users WHERE user_mail = $1 AND password = $2;
-- name: UpdateUserSession :exec
UPDATE users SET session_token = $2, session_expiry = $3 WHERE id = $1;
-- name: logoutUser :exec
-- This is a placeholder for logout functionality, which typically involves token invalidation or session management rather
-- name: GetUserAuthByEmail :one
SELECT id, user_name, user_mail, password
FROM users
WHERE user_mail = $1;
+5
View File
@@ -10,6 +10,7 @@ import (
type UserRepository interface {
CreateUser(ctx context.Context, arg db.CreateUserParams) (pgtype.UUID, error)
GetUsernameByID(ctx context.Context, id pgtype.UUID) (string, error)
GetUserAuthByEmail(ctx context.Context, mail string) (db.GetUserAuthByEmailRow, error)
}
type SQLCUserRepository struct {
@@ -27,3 +28,7 @@ func (r *SQLCUserRepository) CreateUser(ctx context.Context, arg db.CreateUserPa
func (r *SQLCUserRepository) GetUsernameByID(ctx context.Context, id pgtype.UUID) (string, error) {
return r.q.GetUsernameByID(ctx, id)
}
func (r *SQLCUserRepository) GetUserAuthByEmail(ctx context.Context, email string) (db.GetUserAuthByEmailRow, error) {
return r.q.GetUserAuthByEmail(ctx, email)
}
+10 -2
View File
@@ -3,13 +3,14 @@ package routes
import (
"net/http"
"zardzul/music-index/handlers"
"zardzul/music-index/middleware"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
func Routes(router *gin.Engine, userHandler *handlers.UserHandler, artistHandler *handlers.ArtistHandler) {
func Routes(router *gin.Engine, userHandler *handlers.UserHandler, artistHandler *handlers.ArtistHandler, jwtSecret string) {
root := router.Group("/api/v1")
{
root.GET("/ping", func(c *gin.Context) {
@@ -21,10 +22,17 @@ func Routes(router *gin.Engine, userHandler *handlers.UserHandler, artistHandler
user := root.Group("/users")
{
user.POST("/create", userHandler.CreateUser)
user.GET("/:id", userHandler.GetUsernameByID)
user.POST("/login", userHandler.Login)
}
protectedUser := root.Group("/users")
protectedUser.Use(middleware.JWTAuth(jwtSecret))
{
protectedUser.GET("/:id", userHandler.GetUsernameByID)
}
artist := root.Group("/artists")
artist.Use(middleware.JWTAuth(jwtSecret))
{
artist.GET("/", artistHandler.GetAll)
}
+5 -7
View File
@@ -25,13 +25,11 @@ type Artist struct {
}
type User struct {
ID pgtype.UUID `json:"id"`
UserName string `json:"user_name"`
UserMail string `json:"user_mail"`
Password string `json:"password"`
CreatedAt pgtype.Timestamp `json:"created_at"`
SessionToken pgtype.Text `json:"session_token"`
SessionExpiry pgtype.Timestamp `json:"session_expiry"`
ID pgtype.UUID `json:"id"`
UserName string `json:"user_name"`
UserMail string `json:"user_mail"`
Password string `json:"password"`
CreatedAt pgtype.Timestamp `json:"created_at"`
}
type UserAlbum struct {
+1 -3
View File
@@ -12,7 +12,6 @@ import (
type Querier interface {
AddUserAlbum(ctx context.Context, arg AddUserAlbumParams) error
CheckUserExistsByEmail(ctx context.Context, userMail string) (pgtype.UUID, error)
CreateAlbum(ctx context.Context, arg CreateAlbumParams) (pgtype.UUID, error)
CreateArtist(ctx context.Context, arg CreateArtistParams) (pgtype.UUID, error)
// users.sql
@@ -29,9 +28,9 @@ type Querier interface {
GetUserAlbum(ctx context.Context, arg GetUserAlbumParams) (GetUserAlbumRow, error)
// user_albums.sql
GetUserAlbums(ctx context.Context, userID pgtype.UUID) ([]GetUserAlbumsRow, error)
GetUserAuthByEmail(ctx context.Context, userMail string) (GetUserAuthByEmailRow, error)
GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDRow, error)
GetUsernameByID(ctx context.Context, id pgtype.UUID) (string, error)
LoginUser(ctx context.Context, arg LoginUserParams) (LoginUserRow, error)
RemoveUserAlbum(ctx context.Context, arg RemoveUserAlbumParams) error
SearchAlbums(ctx context.Context, dollar_1 pgtype.Text) ([]Album, error)
SearchArtists(ctx context.Context, dollar_1 pgtype.Text) ([]Artist, error)
@@ -39,7 +38,6 @@ type Querier interface {
UpdateArtist(ctx context.Context, arg UpdateArtistParams) error
UpdateUser(ctx context.Context, arg UpdateUserParams) error
UpdateUserAlbumStatus(ctx context.Context, arg UpdateUserAlbumStatusParams) error
UpdateUserSession(ctx context.Context, arg UpdateUserSessionParams) error
}
var _ Querier = (*Queries)(nil)
+25 -48
View File
@@ -11,17 +11,6 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const checkUserExistsByEmail = `-- name: CheckUserExistsByEmail :one
SELECT id FROM users WHERE user_mail = $1
`
func (q *Queries) CheckUserExistsByEmail(ctx context.Context, userMail string) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, checkUserExistsByEmail, userMail)
var id pgtype.UUID
err := row.Scan(&id)
return id, err
}
const createUser = `-- name: CreateUser :one
INSERT INTO users (id, user_name, user_mail, password) VALUES ($1, $2, $3, $4) RETURNING id
`
@@ -46,6 +35,31 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (pgtype.
return id, err
}
const getUserAuthByEmail = `-- name: GetUserAuthByEmail :one
SELECT id, user_name, user_mail, password
FROM users
WHERE user_mail = $1
`
type GetUserAuthByEmailRow struct {
ID pgtype.UUID `json:"id"`
UserName string `json:"user_name"`
UserMail string `json:"user_mail"`
Password string `json:"password"`
}
func (q *Queries) GetUserAuthByEmail(ctx context.Context, userMail string) (GetUserAuthByEmailRow, error) {
row := q.db.QueryRow(ctx, getUserAuthByEmail, userMail)
var i GetUserAuthByEmailRow
err := row.Scan(
&i.ID,
&i.UserName,
&i.UserMail,
&i.Password,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, user_name, user_mail, created_at FROM users WHERE id = $1
`
@@ -80,28 +94,6 @@ func (q *Queries) GetUsernameByID(ctx context.Context, id pgtype.UUID) (string,
return user_name, err
}
const loginUser = `-- name: LoginUser :one
SELECT id, user_name, user_mail FROM users WHERE user_mail = $1 AND password = $2
`
type LoginUserParams struct {
UserMail string `json:"user_mail"`
Password string `json:"password"`
}
type LoginUserRow struct {
ID pgtype.UUID `json:"id"`
UserName string `json:"user_name"`
UserMail string `json:"user_mail"`
}
func (q *Queries) LoginUser(ctx context.Context, arg LoginUserParams) (LoginUserRow, error) {
row := q.db.QueryRow(ctx, loginUser, arg.UserMail, arg.Password)
var i LoginUserRow
err := row.Scan(&i.ID, &i.UserName, &i.UserMail)
return i, err
}
const updateUser = `-- name: UpdateUser :exec
UPDATE users SET user_name = $2, user_mail = $3, password = $4 WHERE id = $1
`
@@ -122,18 +114,3 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error {
)
return err
}
const updateUserSession = `-- name: UpdateUserSession :exec
UPDATE users SET session_token = $2, session_expiry = $3 WHERE id = $1
`
type UpdateUserSessionParams struct {
ID pgtype.UUID `json:"id"`
SessionToken pgtype.Text `json:"session_token"`
SessionExpiry pgtype.Timestamp `json:"session_expiry"`
}
func (q *Queries) UpdateUserSession(ctx context.Context, arg UpdateUserSessionParams) error {
_, err := q.db.Exec(ctx, updateUserSession, arg.ID, arg.SessionToken, arg.SessionExpiry)
return err
}
+48
View File
@@ -0,0 +1,48 @@
package utils
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserName string `json:"user_name"`
UserMail string `json:"user_mail"`
jwt.RegisteredClaims
}
func GenerateToken(secret, issuer, userID, userName, userMail string, ttl time.Duration) (string, error) {
now := time.Now()
claims := Claims{
UserName: userName,
UserMail: userMail,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
Issuer: issuer,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ValidateToken(secret string, tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if t.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}