diff --git a/cmd/hspguard/api/api.go b/cmd/hspguard/api/api.go index 7d6a62e..c4fe726 100644 --- a/cmd/hspguard/api/api.go +++ b/cmd/hspguard/api/api.go @@ -7,6 +7,7 @@ import ( "os" "gitea.local/admin/hspguard/internal/auth" + "gitea.local/admin/hspguard/internal/config" imiddleware "gitea.local/admin/hspguard/internal/middleware" "gitea.local/admin/hspguard/internal/oauth" "gitea.local/admin/hspguard/internal/repository" @@ -20,9 +21,10 @@ type APIServer struct { addr string repo *repository.Queries storage *storage.FileStorage + cfg *config.AppConfig } -func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage) *APIServer { +func NewAPIServer(addr string, db *repository.Queries, minio *storage.FileStorage, cfg *config.AppConfig) *APIServer { return &APIServer{ addr: addr, repo: db, @@ -38,10 +40,10 @@ func (s *APIServer) Run() error { // staticDir := http.Dir(filepath.Join(workDir, "static")) // FileServer(router, "/static", staticDir) - oauthHandler := oauth.NewOAuthHandler(s.repo) + oauthHandler := oauth.NewOAuthHandler(s.repo, s.cfg) router.Route("/api/v1", func(r chi.Router) { - r.Use(imiddleware.WithSkipper(imiddleware.AuthMiddleware, "/api/v1/login", "/api/v1/register", "/api/v1/oauth/token")) + r.Use(imiddleware.WithSkipper(imiddleware.AuthMiddleware(s.cfg), "/api/v1/login", "/api/v1/register", "/api/v1/oauth/token")) userHandler := user.NewUserHandler(s.repo, s.storage) userHandler.RegisterRoutes(r) @@ -52,8 +54,8 @@ func (s *APIServer) Run() error { oauthHandler.RegisterRoutes(r) }) - router.Get("/.well-known/jwks.json", auth.WriteJWKS) - router.Get("/.well-known/openid-configuration", auth.OpenIdConfiguration) + router.Get("/.well-known/jwks.json", oauthHandler.WriteJWKS) + router.Get("/.well-known/openid-configuration", oauth.OpenIdConfiguration) router.Get("/*", func(w http.ResponseWriter, r *http.Request) { path := "./dist" + r.URL.Path diff --git a/cmd/hspguard/main.go b/cmd/hspguard/main.go index 4eebcb0..f4f61c1 100644 --- a/cmd/hspguard/main.go +++ b/cmd/hspguard/main.go @@ -4,9 +4,9 @@ import ( "context" "fmt" "log" - "os" "gitea.local/admin/hspguard/cmd/hspguard/api" + "gitea.local/admin/hspguard/internal/config" "gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/storage" "gitea.local/admin/hspguard/internal/user" @@ -21,9 +21,17 @@ func main() { return } + var cfg config.AppConfig + + err = config.LoadEnv(&cfg) + if err != nil { + log.Fatal(err) + return + } + ctx := context.Background() - conn, err := pgx.Connect(ctx, os.Getenv("DATABASE_URL")) + conn, err := pgx.Connect(ctx, cfg.DatabaseURL) if err != nil { log.Fatalln("ERR: Failed to connect to db:", err) return @@ -31,21 +39,11 @@ func main() { repo := repository.New(conn) - fStorage := storage.New() + fStorage := storage.New(&cfg) - user.EnsureAdminUser(ctx, repo) + user.EnsureAdminUser(ctx, &cfg, repo) - host := os.Getenv("HOST") - if host == "" { - host = "127.0.0.1" - } - - port := os.Getenv("PORT") - if port == "" { - port = "3000" - } - - server := api.NewAPIServer(fmt.Sprintf("%s:%s", host, port), repo, fStorage) + server := api.NewAPIServer(fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), repo, fStorage, &cfg) if err := server.Run(); err != nil { log.Fatalln("ERR: Failed to start server:", err) } diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 50fb115..fc1a5bf 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -4,71 +4,13 @@ import ( "crypto/rsa" "crypto/x509" "encoding/base64" - "encoding/json" "fmt" - "net/http" - "os" "gitea.local/admin/hspguard/internal/types" - "gitea.local/admin/hspguard/internal/web" "github.com/golang-jwt/jwt/v5" ) -func WriteJWKS(w http.ResponseWriter, r *http.Request) { - pubKey, err := parseBase64PublicKey("JWT_PUBLIC_KEY") - if err != nil { - web.Error(w, "failed to parse public key", http.StatusInternalServerError) - } - - n := base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes()) - e := base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}) // 65537 = 0x010001 - - jwks := map[string]interface{}{ - "keys": []map[string]string{ - { - "kty": "RSA", - "kid": "my-rsa-key-1", - "use": "sig", - "alg": "RS256", - "n": n, - "e": e, - }, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(jwks) -} - -func OpenIdConfiguration(w http.ResponseWriter, r *http.Request) { - type Response struct { - TokenEndpoint string `json:"token_endpoint"` - AuthorizationEndpoint string `json:"authorization_endpoint"` - JwksURI string `json:"jwks_uri"` - Issuer string `json:"issuer"` - EndSessionEndpoint string `json:"end_session_endpoint"` - } - - w.Header().Set("Content-Type", "application/json") - - encoder := json.NewEncoder(w) - if err := encoder.Encode(Response{ - TokenEndpoint: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app/api/v1/oauth/token", - AuthorizationEndpoint: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app/authorize", - JwksURI: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app/.well-known/jwks.json", - Issuer: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app", - EndSessionEndpoint: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app/api/v1/oauth/logout", - }); err != nil { - web.Error(w, "failed to encode response", http.StatusInternalServerError) - } -} - -func parseBase64PrivateKey(envVar string) (*rsa.PrivateKey, error) { - b64 := os.Getenv(envVar) - if b64 == "" { - return nil, fmt.Errorf("env var %s is empty", envVar) - } - +func ParseBase64PrivateKey(b64 string) (*rsa.PrivateKey, error) { decoded, err := base64.StdEncoding.DecodeString(b64) if err != nil { return nil, fmt.Errorf("failed to decode base64 key: %v", err) @@ -78,12 +20,7 @@ func parseBase64PrivateKey(envVar string) (*rsa.PrivateKey, error) { return key.(*rsa.PrivateKey), err } -func parseBase64PublicKey(envVar string) (*rsa.PublicKey, error) { - b64 := os.Getenv(envVar) - if b64 == "" { - return nil, fmt.Errorf("env var %s is empty", envVar) - } - +func ParseBase64PublicKey(b64 string) (*rsa.PublicKey, error) { decoded, err := base64.StdEncoding.DecodeString(b64) if err != nil { return nil, fmt.Errorf("failed to decode base64 key: %v", err) @@ -102,8 +39,8 @@ func parseBase64PublicKey(envVar string) (*rsa.PublicKey, error) { return pubKey, nil } -func SignJwtToken(claims jwt.Claims) (string, error) { - privateKey, err := parseBase64PrivateKey("JWT_PRIVATE_KEY") +func SignJwtToken(claims jwt.Claims, key string) (string, error) { + privateKey, err := ParseBase64PrivateKey(key) if err != nil { return "", err } @@ -120,8 +57,8 @@ func SignJwtToken(claims jwt.Claims) (string, error) { return s, nil } -func VerifyToken(token string) (*jwt.Token, *types.UserClaims, error) { - publicKey, err := parseBase64PublicKey("JWT_PUBLIC_KEY") +func VerifyToken(token string, key string) (*jwt.Token, *types.UserClaims, error) { + publicKey, err := ParseBase64PublicKey(key) if err != nil { return nil, nil, err } diff --git a/internal/auth/routes.go b/internal/auth/routes.go index 34815f7..1f1f617 100644 --- a/internal/auth/routes.go +++ b/internal/auth/routes.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "gitea.local/admin/hspguard/internal/config" "gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/types" "gitea.local/admin/hspguard/internal/util" @@ -17,6 +18,7 @@ import ( type AuthHandler struct { repo *repository.Queries + cfg *config.AppConfig } func NewAuthHandler(repo *repository.Queries) *AuthHandler { @@ -89,6 +91,7 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) { accessClaims := types.UserClaims{ UserEmail: user.Email, + IsAdmin: user.IsAdmin, RegisteredClaims: jwt.RegisteredClaims{ Issuer: "hspguard", Subject: user.ID.String(), @@ -97,7 +100,7 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) { }, } - accessToken, err := SignJwtToken(accessClaims) + accessToken, err := SignJwtToken(accessClaims, h.cfg.Jwt.PrivateKey) if err != nil { web.Error(w, fmt.Sprintf("failed to generate access token: %v", err), http.StatusBadRequest) return @@ -105,6 +108,7 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) { refreshClaims := types.UserClaims{ UserEmail: user.Email, + IsAdmin: user.IsAdmin, RegisteredClaims: jwt.RegisteredClaims{ Issuer: "hspguard", Subject: user.ID.String(), @@ -113,7 +117,7 @@ func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) { }, } - refreshToken, err := SignJwtToken(refreshClaims) + refreshToken, err := SignJwtToken(refreshClaims, h.cfg.Jwt.PrivateKey) if err != nil { web.Error(w, fmt.Sprintf("failed to generate refresh token: %v", err), http.StatusBadRequest) return diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 6423217..dc69989 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -7,34 +7,37 @@ import ( "strings" "gitea.local/admin/hspguard/internal/auth" + "gitea.local/admin/hspguard/internal/config" "gitea.local/admin/hspguard/internal/types" "gitea.local/admin/hspguard/internal/web" ) -func AuthMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - web.Error(w, "unauthorized", http.StatusUnauthorized) - return - } +func AuthMiddleware(cfg *config.AppConfig) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + web.Error(w, "unauthorized", http.StatusUnauthorized) + return + } - parts := strings.Split(authHeader, "Bearer ") - if len(parts) != 2 { - web.Error(w, "invalid auth header format", http.StatusUnauthorized) - return - } + parts := strings.Split(authHeader, "Bearer ") + if len(parts) != 2 { + web.Error(w, "invalid auth header format", http.StatusUnauthorized) + return + } - tokenStr := parts[1] - token, userClaims, err := auth.VerifyToken(tokenStr) - if err != nil || !token.Valid { - http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized) - return - } + tokenStr := parts[1] + token, userClaims, err := auth.VerifyToken(tokenStr, cfg.Jwt.PublicKey) + if err != nil || !token.Valid { + http.Error(w, fmt.Sprintf("invalid token: %v", err), http.StatusUnauthorized) + return + } - ctx := context.WithValue(r.Context(), types.UserIdKey, userClaims.Subject) - next.ServeHTTP(w, r.WithContext(ctx)) - }) + ctx := context.WithValue(r.Context(), types.UserIdKey, userClaims.Subject) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } } func WithSkipper(mw func(http.Handler) http.Handler, excludedPaths ...string) func(http.Handler) http.Handler { diff --git a/internal/oauth/routes.go b/internal/oauth/routes.go index c429e93..24afd82 100644 --- a/internal/oauth/routes.go +++ b/internal/oauth/routes.go @@ -10,6 +10,7 @@ import ( "time" "gitea.local/admin/hspguard/internal/auth" + "gitea.local/admin/hspguard/internal/config" "gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/types" "gitea.local/admin/hspguard/internal/util" @@ -21,11 +22,13 @@ import ( type OAuthHandler struct { repo *repository.Queries + cfg *config.AppConfig } -func NewOAuthHandler(repo *repository.Queries) *OAuthHandler { +func NewOAuthHandler(repo *repository.Queries, cfg *config.AppConfig) *OAuthHandler { return &OAuthHandler{ repo, + cfg, } } @@ -35,6 +38,55 @@ func (h *OAuthHandler) RegisterRoutes(r chi.Router) { r.Post("/oauth/code", h.getAuthCode) } +func (h *OAuthHandler) WriteJWKS(w http.ResponseWriter, r *http.Request) { + pubKey, err := auth.ParseBase64PublicKey(h.cfg.Jwt.PublicKey) + if err != nil { + web.Error(w, "failed to parse public key", http.StatusInternalServerError) + } + + n := base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes()) + e := base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}) // 65537 = 0x010001 + + jwks := map[string]interface{}{ + "keys": []map[string]string{ + { + "kty": "RSA", + "kid": "my-rsa-key-1", + "use": "sig", + "alg": "RS256", + "n": n, + "e": e, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(jwks) +} + +func OpenIdConfiguration(w http.ResponseWriter, r *http.Request) { + type Response struct { + TokenEndpoint string `json:"token_endpoint"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + JwksURI string `json:"jwks_uri"` + Issuer string `json:"issuer"` + EndSessionEndpoint string `json:"end_session_endpoint"` + } + + w.Header().Set("Content-Type", "application/json") + + encoder := json.NewEncoder(w) + if err := encoder.Encode(Response{ + TokenEndpoint: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app/api/v1/oauth/token", + AuthorizationEndpoint: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app/authorize", + JwksURI: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app/.well-known/jwks.json", + Issuer: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app", + EndSessionEndpoint: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app/api/v1/oauth/logout", + }); err != nil { + web.Error(w, "failed to encode response", http.StatusInternalServerError) + } +} + func (h *OAuthHandler) getAuthCode(w http.ResponseWriter, r *http.Request) { userId, ok := util.GetRequestUserId(r.Context()) if !ok { @@ -155,7 +207,7 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) { }, } - idToken, err := auth.SignJwtToken(claims) + idToken, err := auth.SignJwtToken(claims, h.cfg.Jwt.PrivateKey) if err != nil { web.Error(w, "failed to sign id token", http.StatusInternalServerError) return diff --git a/internal/storage/mod.go b/internal/storage/mod.go index c104bde..ac50219 100644 --- a/internal/storage/mod.go +++ b/internal/storage/mod.go @@ -5,8 +5,8 @@ import ( "io" "log" "net/url" - "os" + "gitea.local/admin/hspguard/internal/config" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) @@ -15,27 +15,9 @@ type FileStorage struct { client *minio.Client } -func New() *FileStorage { - endpoint := os.Getenv("MINIO_ENDPOINT") - if endpoint == "" { - log.Fatalln("MINIO_ENDPOINT env var is required") - return nil - } - - accessKey := os.Getenv("MINIO_ACCESS_KEY") - if accessKey == "" { - log.Fatalln("MINIO_ACCESS_KEY env var is required") - return nil - } - - secretKey := os.Getenv("MINIO_SECRET_KEY") - if secretKey == "" { - log.Fatalln("MINIO_SECRET_KEY env var is required") - return nil - } - - client, err := minio.New(endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(accessKey, secretKey, ""), +func New(cfg *config.AppConfig) *FileStorage { + client, err := minio.New(cfg.Minio.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.Minio.AccessKey, cfg.Minio.SecretKey, ""), Secure: false, }) if err != nil { diff --git a/internal/types/claims.go b/internal/types/claims.go index 38a68db..7fea3c8 100644 --- a/internal/types/claims.go +++ b/internal/types/claims.go @@ -4,6 +4,7 @@ import "github.com/golang-jwt/jwt/v5" type UserClaims struct { UserEmail string `json:"user_email"` + IsAdmin bool `json:"is_admin"` jwt.RegisteredClaims } diff --git a/internal/user/admin.go b/internal/user/admin.go index 480b12b..41ae49d 100644 --- a/internal/user/admin.go +++ b/internal/user/admin.go @@ -4,33 +4,21 @@ import ( "context" "fmt" "log" - "os" + "gitea.local/admin/hspguard/internal/config" "gitea.local/admin/hspguard/internal/repository" "gitea.local/admin/hspguard/internal/util" "github.com/google/uuid" ) -func EnsureAdminUser(ctx context.Context, repo *repository.Queries) { - adminName := os.Getenv("ADMIN_NAME") - if adminName == "" { - adminName = "admin" - } - - adminEmail := os.Getenv("ADMIN_EMAIL") - adminPassword := os.Getenv("ADMIN_PASSWORD") - - if adminEmail == "" { - log.Fatalln("ERR: ADMIN_EMAIL env variable is required") - } - - _, err := repo.FindUserEmail(ctx, adminEmail) +func EnsureAdminUser(ctx context.Context, cfg *config.AppConfig, repo *repository.Queries) { + _, err := repo.FindUserEmail(ctx, cfg.Admin.Email) if err != nil { - if adminPassword == "" { + if cfg.Admin.Password == "" { log.Fatalln("ERR: ADMIN_PASSWORD env variable is required") } - if _, err := createAdmin(ctx, adminName, adminEmail, adminPassword, repo); err != nil { + if _, err := createAdmin(ctx, cfg.Admin.Name, cfg.Admin.Email, cfg.Admin.Password, repo); err != nil { log.Fatalln("ERR: Failed to create admin account:", err) } } diff --git a/web/public/dark-overlay.jpg b/web/public/dark-overlay.jpg index 3fdf231..c67023e 100644 Binary files a/web/public/dark-overlay.jpg and b/web/public/dark-overlay.jpg differ