serve frontend from go
This commit is contained in:
113
internal/auth/controller.go
Normal file
113
internal/auth/controller.go
Normal file
@ -0,0 +1,113 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/internal/model"
|
||||
)
|
||||
|
||||
// Signup handles user signup by decoding request body, hashing the password, and saving user data to the database.
|
||||
func (s *Service) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
var login Login
|
||||
user := model.NewUser()
|
||||
|
||||
if err = json.NewDecoder(r.Body).Decode(&login); err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if len([]byte(login.Password)) > 72 {
|
||||
fmt.Fprint(w, "Password to long, max 72 bytes")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if user.Password, err = bcrypt.GenerateFromPassword([]byte(login.Password), 6); err != nil {
|
||||
log.Println("Error: ", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user.Name = login.Name
|
||||
user.Role = s.cfg.DefaultRole
|
||||
|
||||
err = s.db.Save(&user).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrCheckConstraintViolated) {
|
||||
fmt.Fprint(w, "Username is already in use")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Error: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Login handles user login by decoding request body, verifying credentials, and returning a JWT token.
|
||||
func (s *Service) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var login Login
|
||||
var user model.User
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&login); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.First(&user).Error; err != nil {
|
||||
fmt.Fprint(w, "user not found")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(login.Password)); err != nil {
|
||||
fmt.Fprint(w, "Invalid Password")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
token, err := s.createJWT(&user)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := json.Marshal(&LoginResponse{
|
||||
Token: token,
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("Error: ", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(res)
|
||||
}
|
||||
|
||||
// Logout handles user logout by invalidating the JWT and saving it to the database.
|
||||
func (s *Service) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := extractToken(r)
|
||||
if err != nil {
|
||||
log.Printf("Error while extracting token: %s", err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := s.validateJWT(token)
|
||||
if err != nil {
|
||||
fmt.Fprint(w, "Invalid token")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err = s.db.Save(&model.InvalidJWT{JWT: token, ValidUntil: claims.ExpiresAt.Time}).Error; err != nil {
|
||||
log.Printf("Error while saving logout token: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
22
internal/auth/ctx.go
Normal file
22
internal/auth/ctx.go
Normal file
@ -0,0 +1,22 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type authkey int
|
||||
|
||||
const keyClaims = iota
|
||||
|
||||
func writeToContext(r *http.Request, claims *Claims) *http.Request {
|
||||
ctx := context.WithValue(r.Context(), keyClaims, claims)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
// ExtractClaims extracts user claims from given context. If no claims in context ok = false.
|
||||
func ExtractClaims(ctx context.Context) (claims *Claims, ok bool) {
|
||||
val := ctx.Value(keyClaims)
|
||||
claims, ok = val.(*Claims)
|
||||
return
|
||||
}
|
79
internal/auth/jwt.go
Normal file
79
internal/auth/jwt.go
Normal file
@ -0,0 +1,79 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/internal/model"
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var ErrJWTInvalid = errors.New("JWT not valid")
|
||||
|
||||
func (s *Service) createJWT(user *model.User) (token string, err error) {
|
||||
claims := &Claims{
|
||||
Role: user.Role,
|
||||
UserID: user.ID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: user.UUID.String(),
|
||||
ExpiresAt: &jwt.NumericDate{
|
||||
Time: time.Now().Add(s.cfg.ValidDuration),
|
||||
},
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString([]byte(s.cfg.Secret))
|
||||
}
|
||||
|
||||
// validateJWT returns the token Claims and if token ist invalid ErrJWTInvalid
|
||||
func (s *Service) validateJWT(tokenString string) (claims Claims, err error) {
|
||||
_, err = jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (any, 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(s.cfg.Secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if claims.ExpiresAt.Before(time.Now()) {
|
||||
err = ErrJWTInvalid
|
||||
return
|
||||
}
|
||||
|
||||
// check if user has logged out this token
|
||||
var invalidated bool
|
||||
err = s.db.Model(&model.InvalidJWT{}).
|
||||
Select("count(*) > 0").
|
||||
Where("jwt = ?", tokenString).
|
||||
Find(&invalidated).
|
||||
Error
|
||||
|
||||
if invalidated || err != nil {
|
||||
err = ErrJWTInvalid
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// extractToken extracts the Bearer token from the request
|
||||
func extractToken(r *http.Request) (token string, err error) {
|
||||
tokenHeader := r.Header.Get("Authorization") // Grab the token from the header
|
||||
|
||||
if tokenHeader == "" {
|
||||
err = errors.New("missing token")
|
||||
return
|
||||
}
|
||||
|
||||
token = strings.TrimPrefix(tokenHeader, "Bearer ")
|
||||
|
||||
if token == "" {
|
||||
err = errors.New("malformed token")
|
||||
}
|
||||
return
|
||||
}
|
76
internal/auth/jwt_test.go
Normal file
76
internal/auth/jwt_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/internal/model"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func testDB() (db *gorm.DB) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"))
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
db.AutoMigrate(&model.User{}, &model.InvalidJWT{})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func TestService_JWT(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
user model.User
|
||||
}{
|
||||
{
|
||||
user: model.User{
|
||||
ID: 0,
|
||||
Name: "Hans de Admin",
|
||||
Role: model.RoleAdmin,
|
||||
UUID: uuid.MustParse("9d8973b7-2005-4ca6-a4bf-7bae5aad2916"),
|
||||
},
|
||||
},
|
||||
{
|
||||
user: model.User{
|
||||
ID: 1,
|
||||
Name: "Ueli de User",
|
||||
Role: model.RoleUser,
|
||||
UUID: uuid.MustParse("e1b7099f-a3be-4d77-b33f-389e27123187"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.user.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := New(&Config{
|
||||
Secret: "asdf",
|
||||
ValidDuration: time.Hour,
|
||||
AdminName: "adsf",
|
||||
AdminPassword: "adsf",
|
||||
}, testDB())
|
||||
|
||||
jwt, err := s.createJWT(&tt.user)
|
||||
if err != nil {
|
||||
t.Errorf("Error while creating JWT: %v", err)
|
||||
}
|
||||
|
||||
claims, err := s.validateJWT(jwt)
|
||||
if err != nil {
|
||||
t.Errorf("Error while creating JWT: %v", err)
|
||||
}
|
||||
|
||||
if claims.Subject != tt.user.UUID.String() {
|
||||
t.Error("Subject does not match")
|
||||
}
|
||||
if claims.Role != tt.user.Role {
|
||||
t.Error("Roles did not match")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
35
internal/auth/middleware.go
Normal file
35
internal/auth/middleware.go
Normal file
@ -0,0 +1,35 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/internal/model"
|
||||
)
|
||||
|
||||
// Authenticated: This function is a middleware that authenticates incoming HTTP requests using JWT tokens and role-based access control.
|
||||
func (s *Service) Authenticated(next http.HandlerFunc, roles ...model.Role) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Our middleware logic goes here...
|
||||
token, err := extractToken(r)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := s.validateJWT(token)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// if roles specified check if satisfied
|
||||
if len(roles) > 0 && !slices.Contains(roles, claims.Role) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
r = writeToContext(r, &claims)
|
||||
next(w, r)
|
||||
})
|
||||
}
|
64
internal/auth/resource.go
Normal file
64
internal/auth/resource.go
Normal file
@ -0,0 +1,64 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.schreifuchs.ch/schreifuchs/ng-blog/internal/model"
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Config defines a struct for configuration settings, often loaded from environment variables.
|
||||
type Config struct {
|
||||
Secret string `env:"SECRET"`
|
||||
ValidDuration time.Duration `env:"VALID_DURATION"`
|
||||
AdminName string `env:"ADMIN_NAME"`
|
||||
AdminPassword string `env:"ADMIN_PASSWORD"`
|
||||
DefaultRole model.Role `env:"DEFAULT_ROLE"`
|
||||
}
|
||||
|
||||
// Service Represents a service with configuration and database connection.
|
||||
type Service struct {
|
||||
cfg *Config
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// New creates a new Service instance, initializing a default admin user and saving it to the database.
|
||||
func New(cfg *Config, db *gorm.DB) *Service {
|
||||
user := model.NewUser()
|
||||
var err error
|
||||
if user.Password, err = bcrypt.GenerateFromPassword([]byte(cfg.AdminName), 6); err != nil {
|
||||
log.Fatalf("Error while creating default user: %v", err)
|
||||
}
|
||||
user.Name = cfg.AdminName
|
||||
user.Role = model.RoleAdmin
|
||||
|
||||
// add default user
|
||||
_ = db.Clauses(clause.OnConflict{DoNothing: true}).Save(&user).Error
|
||||
|
||||
return &Service{
|
||||
cfg,
|
||||
db,
|
||||
}
|
||||
}
|
||||
|
||||
// Claims struct represents JWT claims, including role and user ID, extending the standard jwt.RegisteredClaims.
|
||||
type Claims struct {
|
||||
Role model.Role `json:"rl"`
|
||||
UserID uint `json:"uid"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Login struct represents user login credentials with a name and password.
|
||||
type Login struct {
|
||||
Name string `json:"name"`
|
||||
Password string `json:"Password"`
|
||||
}
|
||||
|
||||
// LoginResponse Represents the response from a login endpoint, containing a JWT token.
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
Reference in New Issue
Block a user