diff --git a/.env.example b/.env.example index b286a0b..b96334a 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,6 @@ SQLITE_FILE= # postgres DSN, look at https://gorm.io/docs/connecting_to_the_database.html#PostgreSQL POSTGRES_DSN= + +# Random secret. Recommended length is 64 characters at minimum. +JWT_SECRET= diff --git a/go.mod b/go.mod index 1f25bff..db43dc3 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.4 // indirect diff --git a/go.sum b/go.sum index 9b0df5c..d3761d1 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 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/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/internal/api/routes/auth.go b/internal/api/routes/auth.go index 29c0ad1..709eae4 100644 --- a/internal/api/routes/auth.go +++ b/internal/api/routes/auth.go @@ -2,8 +2,10 @@ package routes import ( "net/http" + "time" "github.com/gin-gonic/gin" + "stereo.cat/backend/internal/auth" "stereo.cat/backend/internal/types" ) @@ -23,6 +25,12 @@ func RegisterAuthRoutes(cfg *types.StereoConfig, api *gin.RouterGroup) { panic(err) } - c.JSON(http.StatusOK, user) + jwt, err := auth.GenerateJWT(cfg.JWTSecret, user, uint64(time.Now().Add(time.Second*time.Duration(t.ExpiresIn)).Unix())) + + if err != nil { + panic(err) + } + + c.String(http.StatusOK, jwt) }) } diff --git a/internal/auth/client/client.go b/internal/auth/client/client.go index 7168fd2..9a415f5 100644 --- a/internal/auth/client/client.go +++ b/internal/auth/client/client.go @@ -8,8 +8,6 @@ import ( "net/http" "net/url" "strings" - "time" - "stereo.cat/backend/internal/auth" ) diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..d2ee5d7 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,37 @@ +package auth + +import ( + "fmt" + + "github.com/golang-jwt/jwt/v5" +) + +func GenerateJWT(key string, user User, expiryTimestamp uint64) (string, error) { + claims := jwt.MapClaims{ + "user": user, + "exp": expiryTimestamp, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(key)) +} + +func ValidateJWT(jwtString, key string) (jwt.MapClaims, error) { + token, err := jwt.Parse(jwtString, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Invalid signing method!") + } + + return []byte(key), nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + return claims, nil + } + + return nil, fmt.Errorf("Invalid token!") +} diff --git a/internal/auth/types.go b/internal/auth/types.go index cd07010..2b61913 100644 --- a/internal/auth/types.go +++ b/internal/auth/types.go @@ -1,23 +1,21 @@ package auth -import ( - "gorm.io/gorm" -) +import "time" type TokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` - ExpiresIn uint64 `json:"expires_in"` + ExpiresIn int64 `json:"expires_in"` RefreshToken string `json:"refresh_token"` Scope string `json:"scope"` } type User struct { - gorm.Model - ID string `json:"id" gorm:"primaryKey;autoIncrement:false"` + ID string `json:"id" gorm:"primaryKey"` Username string `json:"username"` Blacklisted bool Email string `json:"email"` + CreatedAt time.Time } type AvatarDecorationData struct { diff --git a/internal/types/types.go b/internal/types/types.go index fcafa83..c29a507 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -17,4 +17,5 @@ type StereoConfig struct { Router *gin.Engine Client client.Client Database *gorm.DB + JWTSecret string } diff --git a/main.go b/main.go index 90a965f..f8a4ae7 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "errors" + "fmt" "log" "os" @@ -23,6 +24,13 @@ func getEnv(key, fallback string) string { return fallback } +func requireEnv(key string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + panic(errors.New(fmt.Sprintf("Environment variable %s is required but not specified. Exiting...", key))) +} + func main() { _ = godotenv.Load() @@ -42,39 +50,40 @@ func main() { Router: gin.Default(), ImagePath: imagePath, Client: client.New( - os.Getenv("REDIRECT_URI"), - os.Getenv("CLIENT_ID"), - os.Getenv("CLIENT_SECRET"), + requireEnv("REDIRECT_URI"), + requireEnv("CLIENT_ID"), + requireEnv("CLIENT_SECRET"), ), + JWTSecret: requireEnv("JWT_SECRET"), } switch databaseType { - case "sqlite": - db, err := gorm.Open(sqlite.Open(sqliteFile), &gorm.Config{}) + case "sqlite": + db, err := gorm.Open(sqlite.Open(sqliteFile), &gorm.Config{}) - c.Database = db + c.Database = db - if err != nil { - panic(err) - } + if err != nil { + panic(err) + } - break + break - case "postgres": - db, err := gorm.Open(postgres.Open(os.Getenv("POSTGRES_DSN")), &gorm.Config{}) + case "postgres": + db, err := gorm.Open(postgres.Open(requireEnv("POSTGRES_DSN")), &gorm.Config{}) - c.Database = db + c.Database = db - if err != nil { - panic(err) - } + if err != nil { + panic(err) + } - break - default: - panic(errors.New("Invalid database type was specified.")) + break + default: + panic(errors.New("Invalid database type was specified.")) } - c.Database.AutoMigrate(&auth.User{}) + c.Database.AutoMigrate(&auth.User{}) api.Register(&c)