Compare commits
9 Commits
cb91a10192
...
eba6253ff6
Author | SHA1 | Date | |
---|---|---|---|
eba6253ff6
|
|||
ef7e1a80d9
|
|||
854e1b44a9
|
|||
607174110c
|
|||
c283998403
|
|||
986ca8e353
|
|||
20d9947642
|
|||
97e15e1b1e
|
|||
d50bd6c4f5
|
@ -7,6 +7,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"gitea.local/admin/hspguard/internal/auth"
|
||||||
|
imiddleware "gitea.local/admin/hspguard/internal/middleware"
|
||||||
"gitea.local/admin/hspguard/internal/repository"
|
"gitea.local/admin/hspguard/internal/repository"
|
||||||
"gitea.local/admin/hspguard/internal/user"
|
"gitea.local/admin/hspguard/internal/user"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@ -34,8 +36,13 @@ func (s *APIServer) Run() error {
|
|||||||
FileServer(router, "/static", staticDir)
|
FileServer(router, "/static", staticDir)
|
||||||
|
|
||||||
router.Route("/api/v1", func(r chi.Router) {
|
router.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Use(imiddleware.WithSkipper(imiddleware.AuthMiddleware, "/api/v1/login", "/api/v1/register"))
|
||||||
|
|
||||||
userHandler := user.NewUserHandler(s.repo)
|
userHandler := user.NewUserHandler(s.repo)
|
||||||
userHandler.RegisterRoutes(router, r)
|
userHandler.RegisterRoutes(router, r)
|
||||||
|
|
||||||
|
authHandler := auth.NewAuthHandler(s.repo)
|
||||||
|
authHandler.RegisterRoutes(router, r)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle unknown routes
|
// Handle unknown routes
|
||||||
@ -48,7 +55,7 @@ func (s *APIServer) Run() error {
|
|||||||
router.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
|
router.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
fmt.Fprint(w, `{"error": "405 - method not allowed"}`)
|
_, _ = fmt.Fprint(w, `{"error": "405 - method not allowed"}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
log.Println("Listening on", s.addr)
|
log.Println("Listening on", s.addr)
|
||||||
|
@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"gitea.local/admin/hspguard/internal/types"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -63,12 +64,13 @@ func SignJwtToken(claims jwt.Claims) (string, error) {
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func VerifyToken(token string, claims jwt.Claims) (*jwt.Token, error) {
|
func VerifyToken(token string) (*jwt.Token, *types.UserClaims, error) {
|
||||||
publicKey, err := parseBase64PublicKey("JWT_PUBLIC_KEY")
|
publicKey, err := parseBase64PublicKey("JWT_PUBLIC_KEY")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
claims := &types.UserClaims{}
|
||||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
||||||
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
|
if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||||
@ -77,13 +79,13 @@ func VerifyToken(token string, claims jwt.Claims) (*jwt.Token, error) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid token: %w", err)
|
return nil, nil, fmt.Errorf("invalid token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !parsed.Valid {
|
if !parsed.Valid {
|
||||||
return nil, fmt.Errorf("token is not valid")
|
return nil, nil, fmt.Errorf("token is not valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed, nil
|
return parsed, claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,18 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.local/admin/hspguard/internal/repository"
|
"gitea.local/admin/hspguard/internal/repository"
|
||||||
|
"gitea.local/admin/hspguard/internal/types"
|
||||||
|
"gitea.local/admin/hspguard/internal/util"
|
||||||
|
"gitea.local/admin/hspguard/internal/web"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
@ -16,6 +26,87 @@ func NewAuthHandler(repo *repository.Queries) *AuthHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) RegisterRoutes(router chi.Router, api chi.Router) {
|
func (h *AuthHandler) RegisterRoutes(router chi.Router, api chi.Router) {
|
||||||
|
api.Get("/profile", h.getProfile)
|
||||||
|
api.Post("/login", h.login)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) getProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userId, ok := util.GetRequestUserId(r.Context())
|
||||||
|
if !ok {
|
||||||
|
web.Error(w, "failed to get user id from auth session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.repo.FindUserId(r.Context(), uuid.MustParse(userId))
|
||||||
|
if err != nil {
|
||||||
|
web.Error(w, "user with provided id does not exist", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"full_name": user.FullName,
|
||||||
|
"email": user.Email,
|
||||||
|
"phoneNumber": user.PhoneNumber,
|
||||||
|
"isAdmin": user.IsAdmin,
|
||||||
|
"last_login": user.LastLogin,
|
||||||
|
"updated_at": user.UpdatedAt,
|
||||||
|
"created_at": user.CreatedAt,
|
||||||
|
}); err != nil {
|
||||||
|
web.Error(w, "failed to encode user profile", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginParams struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var params LoginParams
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
if err := decoder.Decode(¶ms); err != nil {
|
||||||
|
web.Error(w, "failed to parse request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Email == "" || params.Password == "" {
|
||||||
|
web.Error(w, "missing required fields", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.repo.FindUserEmail(r.Context(), params.Email)
|
||||||
|
if err != nil {
|
||||||
|
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := types.UserClaims{
|
||||||
|
UserID: user.ID.String(),
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "hspguard",
|
||||||
|
Subject: user.Email,
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := SignJwtToken(claims)
|
||||||
|
if err != nil {
|
||||||
|
web.Error(w, fmt.Sprintf("failed to generate access token: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(w)
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := encoder.Encode(Response{
|
||||||
|
Token: token,
|
||||||
|
}); err != nil {
|
||||||
|
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
53
internal/middleware/auth.go
Normal file
53
internal/middleware/auth.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.local/admin/hspguard/internal/auth"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), types.UserIdKey, userClaims.UserID)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithSkipper(mw func(http.Handler) http.Handler, excludedPaths ...string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
for _, path := range excludedPaths {
|
||||||
|
if strings.HasPrefix(r.URL.Path, path) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mw(next).ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,6 +66,27 @@ func (q *Queries) FindUserEmail(ctx context.Context, email string) (User, error)
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const findUserId = `-- name: FindUserId :one
|
||||||
|
SELECT id, email, full_name, password_hash, is_admin, created_at, updated_at, last_login, phone_number FROM users WHERE id = $1 LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) FindUserId(ctx context.Context, id uuid.UUID) (User, error) {
|
||||||
|
row := q.db.QueryRow(ctx, findUserId, id)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.FullName,
|
||||||
|
&i.PasswordHash,
|
||||||
|
&i.IsAdmin,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.LastLogin,
|
||||||
|
&i.PhoneNumber,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const insertUser = `-- name: InsertUser :one
|
const insertUser = `-- name: InsertUser :one
|
||||||
INSERT INTO users (
|
INSERT INTO users (
|
||||||
email, full_name, password_hash, is_admin
|
email, full_name, password_hash, is_admin
|
||||||
|
10
internal/types/claims.go
Normal file
10
internal/types/claims.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
type UserClaims struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
// Role
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
6
internal/types/middleware.go
Normal file
6
internal/types/middleware.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const UserIdKey contextKey = "userID"
|
||||||
|
|
@ -3,15 +3,11 @@ package user
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.local/admin/hspguard/internal/auth"
|
|
||||||
"gitea.local/admin/hspguard/internal/repository"
|
"gitea.local/admin/hspguard/internal/repository"
|
||||||
"gitea.local/admin/hspguard/internal/web"
|
"gitea.local/admin/hspguard/internal/web"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserHandler struct {
|
type UserHandler struct {
|
||||||
@ -28,7 +24,6 @@ func (h *UserHandler) RegisterRoutes(router chi.Router, api chi.Router) {
|
|||||||
router.Get("/login", h.loginPage)
|
router.Get("/login", h.loginPage)
|
||||||
router.Get("/register", h.registerPage)
|
router.Get("/register", h.registerPage)
|
||||||
api.Post("/register", h.register)
|
api.Post("/register", h.register)
|
||||||
api.Post("/login", h.login)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *UserHandler) loginPage(w http.ResponseWriter, r *http.Request) {
|
func (h *UserHandler) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -98,62 +93,3 @@ func (h *UserHandler) register(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginParams struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserClaims struct {
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
// Role
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *UserHandler) login(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var params LoginParams
|
|
||||||
|
|
||||||
decoder := json.NewDecoder(r.Body)
|
|
||||||
if err := decoder.Decode(¶ms); err != nil {
|
|
||||||
web.Error(w, "failed to parse request body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if params.Email == "" || params.Password == "" {
|
|
||||||
web.Error(w, "missing required fields", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := h.repo.FindUserEmail(context.Background(), params.Email)
|
|
||||||
if err != nil {
|
|
||||||
web.Error(w, "user with provided email does not exists", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims := UserClaims{
|
|
||||||
UserID: user.ID.String(),
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
|
||||||
Issuer: "hspguard",
|
|
||||||
Subject: user.Email,
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := auth.SignJwtToken(claims)
|
|
||||||
if err != nil {
|
|
||||||
web.Error(w, fmt.Sprintf("failed to generate access token: %v", err), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
encoder := json.NewEncoder(w)
|
|
||||||
|
|
||||||
type Response struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := encoder.Encode(Response{
|
|
||||||
Token: token,
|
|
||||||
}); err != nil {
|
|
||||||
web.Error(w, "failed to encode response", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
13
internal/util/request.go
Normal file
13
internal/util/request.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.local/admin/hspguard/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetRequestUserId(ctx context.Context) (string, bool) {
|
||||||
|
userId, ok := ctx.Value(types.UserIdKey).(string)
|
||||||
|
return userId, ok
|
||||||
|
}
|
||||||
|
|
@ -11,3 +11,6 @@ RETURNING id;
|
|||||||
|
|
||||||
-- name: FindUserEmail :one
|
-- name: FindUserEmail :one
|
||||||
SELECT * FROM users WHERE email = $1 LIMIT 1;
|
SELECT * FROM users WHERE email = $1 LIMIT 1;
|
||||||
|
|
||||||
|
-- name: FindUserId :one
|
||||||
|
SELECT * FROM users WHERE id = $1 LIMIT 1;
|
||||||
|
Reference in New Issue
Block a user