This commit is contained in:
2025-03-13 02:51:40 -04:00
parent 46d93a62a1
commit 5657d40c02
24 changed files with 1133 additions and 31 deletions

View File

@ -0,0 +1,15 @@
package database
import (
"github.com/spotdemo4/trevstack/server/internal/models"
"gorm.io/gorm"
)
func Migrate(db *gorm.DB) error {
err := db.AutoMigrate(&models.User{})
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,16 @@
package database
import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func NewPostgresConnection(user, pass, host, port, name string) (*gorm.DB, error) {
dsn := "host=" + host + " user=" + user + " password=" + pass + " dbname=" + name + " port=" + port + " sslmode=disable TimeZone=UTC"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
return db, nil
}

View File

@ -0,0 +1,33 @@
package database
import (
"os"
"path/filepath"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func NewSQLiteConnection(name string) (*gorm.DB, error) {
// Find config diretory
configDir, err := os.UserConfigDir()
if err != nil {
return nil, err
}
// Create database directory if not exists
settingsPath := filepath.Join(configDir, "trevstack")
err = os.MkdirAll(settingsPath, 0766)
if err != nil {
return nil, err
}
// Open database
dbPath := filepath.Join(settingsPath, name)
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return nil, err
}
return db, nil
}

View File

@ -0,0 +1,122 @@
package handlers
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"connectrpc.com/connect"
"github.com/golang-jwt/jwt/v5"
"github.com/spotdemo4/trevstack/server/internal/models"
userv1 "github.com/spotdemo4/trevstack/server/internal/services/user/v1"
"github.com/spotdemo4/trevstack/server/internal/services/user/v1/userv1connect"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type AuthHandler struct {
db *gorm.DB
key []byte
}
func (s *AuthHandler) Login(ctx context.Context, req *connect.Request[userv1.LoginRequest]) (*connect.Response[userv1.LoginResponse], error) {
// Validate
user := models.User{}
if err := s.db.First(&user, "username = ?", req.Msg.Username).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("invalid username or password"))
}
return nil, connect.NewError(connect.CodeInternal, err)
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Msg.Password)); err != nil {
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("invalid username or password"))
}
// Generate JWT
t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
Issuer: "trevstack",
Subject: strconv.FormatUint(uint64(user.ID), 10),
IssuedAt: &jwt.NumericDate{
Time: time.Now(),
},
ExpiresAt: &jwt.NumericDate{
Time: time.Now().Add(time.Hour * 24),
},
})
ss, err := t.SignedString(s.key)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
// Create cookie
cookie := http.Cookie{
Name: "token",
Value: ss,
Path: "/",
MaxAge: 86400,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
}
res := connect.NewResponse(&userv1.LoginResponse{
Token: ss,
})
res.Header().Set("Set-Cookie", cookie.String())
return res, nil
}
func (s *AuthHandler) SignUp(ctx context.Context, req *connect.Request[userv1.SignUpRequest]) (*connect.Response[userv1.SignUpResponse], error) {
// Validate
if err := s.db.First(&models.User{}, "username = ?", req.Msg.Username).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, connect.NewError(connect.CodeInternal, err)
}
} else {
return nil, connect.NewError(connect.CodeAlreadyExists, errors.New("username already exists"))
}
// Hash password
hash, err := bcrypt.GenerateFromPassword([]byte(req.Msg.Password), bcrypt.DefaultCost)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
// Create user
user := models.User{
Username: req.Msg.Username,
Password: string(hash),
}
if err := s.db.Create(&user).Error; err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
res := connect.NewResponse(&userv1.SignUpResponse{})
return res, nil
}
func (s *AuthHandler) Logout(ctx context.Context, req *connect.Request[userv1.LogoutRequest]) (*connect.Response[userv1.LogoutResponse], error) {
// Clear cookie
cookie := http.Cookie{
Name: "token",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
}
res := connect.NewResponse(&userv1.LogoutResponse{})
res.Header().Set("Set-Cookie", cookie.String())
return res, nil
}
func NewAuthHandler(db *gorm.DB, key string) (string, http.Handler) {
return userv1connect.NewAuthServiceHandler(&AuthHandler{
db: db,
key: []byte(key),
})
}

View File

@ -0,0 +1,109 @@
package handlers
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"connectrpc.com/connect"
"github.com/golang-jwt/jwt/v5"
"github.com/spotdemo4/trevstack/server/internal/interceptors"
"github.com/spotdemo4/trevstack/server/internal/models"
userv1 "github.com/spotdemo4/trevstack/server/internal/services/user/v1"
"github.com/spotdemo4/trevstack/server/internal/services/user/v1/userv1connect"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type UserHandler struct {
db *gorm.DB
key []byte
}
func (s *UserHandler) ChangePassword(ctx context.Context, req *connect.Request[userv1.ChangePasswordRequest]) (*connect.Response[userv1.ChangePasswordResponse], error) {
userid, ok := interceptors.UserFromContext(ctx)
if !ok {
return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated"))
}
// Get user
user := models.User{}
if err := s.db.First(&user, "id = ?", userid).Error; err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
// Validate
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Msg.OldPassword)); err != nil {
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("invalid password"))
}
if req.Msg.NewPassword != req.Msg.ConfirmPassword {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("passwords do not match"))
}
// Hash password
hash, err := bcrypt.GenerateFromPassword([]byte(req.Msg.NewPassword), bcrypt.DefaultCost)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
// Update password
if err := s.db.Model(&user).Update("password", string(hash)).Error; err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
res := connect.NewResponse(&userv1.ChangePasswordResponse{})
return res, nil
}
func (s *UserHandler) APIKey(ctx context.Context, req *connect.Request[userv1.APIKeyRequest]) (*connect.Response[userv1.APIKeyResponse], error) {
userid, ok := interceptors.UserFromContext(ctx)
if !ok {
return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated"))
}
// Get user
user := models.User{}
if err := s.db.First(&user, "id = ?", userid).Error; err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
// Validate
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Msg.Password)); err != nil {
return nil, connect.NewError(connect.CodePermissionDenied, errors.New("invalid username or password"))
}
if req.Msg.Password != req.Msg.ConfirmPassword {
return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("passwords do not match"))
}
// Generate JWT
t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
Issuer: "trevstack",
Subject: strconv.FormatUint(uint64(user.ID), 10),
IssuedAt: &jwt.NumericDate{
Time: time.Now(),
},
})
ss, err := t.SignedString(s.key)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
res := connect.NewResponse(&userv1.APIKeyResponse{
Key: ss,
})
return res, nil
}
func NewUserHandler(db *gorm.DB, key string) (string, http.Handler) {
interceptors := connect.WithInterceptors(interceptors.NewAuthInterceptor(key))
return userv1connect.NewUserServiceHandler(
&UserHandler{
db: db,
key: []byte(key),
},
interceptors,
)
}

View File

@ -0,0 +1,187 @@
package interceptors
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"connectrpc.com/connect"
"github.com/golang-jwt/jwt/v5"
)
type authInterceptor struct {
key string
}
func NewAuthInterceptor(key string) *authInterceptor {
return &authInterceptor{
key: key,
}
}
func (i *authInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
// Same as previous UnaryInterceptorFunc.
return connect.UnaryFunc(func(
ctx context.Context,
req connect.AnyRequest,
) (connect.AnyResponse, error) {
// Check if the request is from a client
if req.Spec().IsClient {
return next(ctx, req)
}
// Check if the request contains a valid cookie token
cookies := getCookies(req.Header().Get("Cookie"))
for _, cookie := range cookies {
if cookie.Name == "token" {
subject, err := validateToken(cookie.Value, i.key)
if err == nil {
ctx, err = i.newContext(ctx, subject)
if err == nil {
return next(ctx, req)
}
}
}
}
// Check if the request contains a valid authorization bearer token
authorization := req.Header().Get("Authorization")
if authorization != "" && len(authorization) > 7 {
subject, err := validateToken(authorization[7:], i.key)
if err == nil {
ctx, err = i.newContext(ctx, subject)
if err == nil {
return next(ctx, req)
}
}
}
return nil, connect.NewError(
connect.CodeUnauthenticated,
errors.New("could not authenticate"),
)
})
}
func (*authInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {
return connect.StreamingClientFunc(func(
ctx context.Context,
spec connect.Spec,
) connect.StreamingClientConn {
return next(ctx, spec)
})
}
func (i *authInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
return connect.StreamingHandlerFunc(func(
ctx context.Context,
conn connect.StreamingHandlerConn,
) error {
// Check if the request contains a valid cookie token
cookies := getCookies(conn.RequestHeader().Get("Cookie"))
for _, cookie := range cookies {
if cookie.Name == "token" {
subject, err := validateToken(cookie.Value, i.key)
if err == nil {
ctx, err = i.newContext(ctx, subject)
if err == nil {
return next(ctx, conn)
}
}
}
}
// Check if the request contains a valid authorization bearer token
authorization := conn.RequestHeader().Get("Authorization")
if authorization != "" && len(authorization) > 7 {
subject, err := validateToken(authorization[7:], i.key)
if err == nil {
ctx, err = i.newContext(ctx, subject)
if err == nil {
return next(ctx, conn)
}
}
}
return connect.NewError(
connect.CodeUnauthenticated,
errors.New("could not authenticate"),
)
})
}
func getCookies(rawCookies string) []*http.Cookie {
header := http.Header{}
header.Add("Cookie", rawCookies)
request := http.Request{Header: header}
return request.Cookies()
}
func validateToken(tokenString string, key string) (subject string, err error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(key), nil
})
if err != nil {
return "", err
}
switch {
case token.Valid:
subject, err := token.Claims.GetSubject()
if err != nil {
return "", err
}
return subject, nil
case errors.Is(err, jwt.ErrTokenMalformed):
log.Println("Token is malformed")
return "", err
case errors.Is(err, jwt.ErrSignatureInvalid):
log.Println("Token signature is invalid")
return "", err
case errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet):
log.Println("Token is expired or not valid yet")
return "", err
default:
log.Println("Token is invalid")
return "", err
}
}
// key is an unexported type for keys defined in this package.
// This prevents collisions with keys defined in other packages.
type key int
// userKey is the key for user.User values in Contexts. It is
// unexported; clients use user.NewContext and user.FromContext
// instead of using this key directly.
var userKey key
// NewContext returns a new Context that carries value u.
func (i *authInterceptor) newContext(ctx context.Context, subject string) (context.Context, error) {
id, err := strconv.Atoi(subject)
if err != nil {
return nil, err
}
return context.WithValue(ctx, userKey, id), nil
}
// FromContext returns the User value stored in ctx, if any.
func UserFromContext(ctx context.Context) (int, bool) {
u, ok := ctx.Value(userKey).(int)
return u, ok
}

View File

@ -0,0 +1,8 @@
package models
type User struct {
ID uint32 `gorm:"primaryKey"`
Username string
Password string
}