package controller import ( "crypto/ed25519" "encoding/hex" "errors" "fmt" "git.lumeweb.com/LumeWeb/portal/db" "git.lumeweb.com/LumeWeb/portal/logger" "git.lumeweb.com/LumeWeb/portal/model" "github.com/joomcode/errorx" "github.com/kataras/iris/v12" "github.com/kataras/jwt" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" "strings" "time" ) var sharedKey = []byte("sercrethatmaycontainch@r$32chars") var blocklist *jwt.Blocklist func init() { blocklist = jwt.NewBlocklist(1 * time.Hour) } type AuthController struct { Ctx iris.Context } type LoginRequest struct { Email string `json:"email"` Password string `json:"password"` } type LoginResponse struct { Token string `json:"token"` } type LogoutRequest struct { Token string `json:"token"` } type ChallengeRequest struct { Pubkey string `json:"pubkey"` } type ChallengeResponse struct { Challenge string `json:"challenge"` } type PubkeyLoginRequest struct { Pubkey string `json:"pubkey"` Challenge string `json:"challenge"` Signature string `json:"signature"` } // verifyPassword compares the provided plaintext password with a hashed password and returns an error if they don't match. func verifyPassword(hashedPassword, password string) error { err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) if err != nil { return errors.New("invalid email or password") } return nil } // generateToken generates a JWT token for the given account ID. func generateToken(maxAge time.Duration) (string, error) { // Define the JWT claims. claim := jwt.Claims{ Expiry: time.Now().Add(time.Hour * 24).Unix(), // Token expires in 24 hours. IssuedAt: time.Now().Unix(), } token, err := jwt.Sign(jwt.HS256, sharedKey, claim, jwt.MaxAge(maxAge)) if err != nil { logger.Get().Error("failed to sign jwt", zap.Error(err)) return "", err } return string(token), nil } func generateAndSaveLoginToken(accountID uint, maxAge time.Duration) (string, error) { // Generate a JWT token for the authenticated user. token, err := generateToken(maxAge) if err != nil { logger.Get().Error("failed to generate token", zap.Error(err)) return "", fmt.Errorf("failed to generate token: %s", err) } verifiedToken, _ := jwt.Verify(jwt.HS256, sharedKey, []byte(token), blocklist) var claim *jwt.Claims _ = verifiedToken.Claims(&claim) // Save the token to the database. session := model.LoginSession{ Account: model.Account{ID: accountID}, Token: token, Expiration: claim.ExpiresAt(), } if err := db.Get().Create(&session).Error; err != nil { msg := "failed to save token" logger.Get().Error(msg, zap.Error(err)) return "", errorx.Decorate(err, msg) } return token, nil } func generateAndSaveChallengeToken(accountID uint, maxAge time.Duration) (string, error) { // Generate a JWT token for the authenticated user. token, err := generateToken(maxAge) if err != nil { logger.Get().Error("failed to generate token", zap.Error(err)) return "", fmt.Errorf("failed to generate token: %s", err) } verifiedToken, _ := jwt.Verify(jwt.HS256, sharedKey, []byte(token), blocklist) var claim *jwt.Claims _ = verifiedToken.Claims(&claim) // Save the token to the database. keyChallenge := model.KeyChallenge{ AccountID: accountID, Challenge: token, Expiration: claim.ExpiresAt(), } if err := db.Get().Create(&keyChallenge).Error; err != nil { msg := "failed to save token" logger.Get().Error(msg, zap.Error(err)) return "", errorx.Decorate(err, msg) } return token, nil } // PostLogin handles the POST /api/auth/login request to authenticate a user and return a JWT token. func (a *AuthController) PostLogin() { var r LoginRequest // Read the login request from the client. if err := a.Ctx.ReadJSON(&r); err != nil { logger.Get().Debug("failed to parse request", zap.Error(err)) a.Ctx.StopWithError(iris.StatusBadRequest, err) return } // Retrieve the account for the given email. account := model.Account{} if err := db.Get().Where("email = ?", r.Email).First(&account).Error; err != nil { msg := "invalid email or password" logger.Get().Debug(msg, zap.Error(err)) a.Ctx.StopWithError(iris.StatusBadRequest, errors.New(msg)) return } if account.Password == nil || len(*account.Password) == 0 { msg := "only pubkey login is supported" logger.Get().Debug(msg) a.Ctx.StopWithError(iris.StatusBadRequest, errors.New(msg)) return } // Verify the provided password against the hashed password stored in the database. if err := verifyPassword(*account.Password, r.Password); err != nil { msg := "invalid email or password" logger.Get().Debug(msg, zap.Error(err)) a.Ctx.StopWithError(iris.StatusBadRequest, errors.New(msg)) return } // Generate a JWT token for the authenticated user. token, err := generateAndSaveLoginToken(account.ID, 24*time.Hour) if err != nil { logger.Get().Debug("failed to generate token", zap.Error(err)) a.Ctx.StopWithError(iris.StatusInternalServerError, fmt.Errorf("failed to generate token: %s", err)) return } // Return the JWT token to the client. err = a.Ctx.JSON(&LoginResponse{Token: token}) if err != nil { logger.Get().Error("failed to generate response", zap.Error(err)) } } // PostChallenge handles the POST /api/auth/pubkey/challenge request to generate a challenge for a user's public key. func (a *AuthController) PostPubkeyChallenge() { var r ChallengeRequest // Read the login request from the client. if err := a.Ctx.ReadJSON(&r); err != nil { logger.Get().Debug("failed to parse request", zap.Error(err)) a.Ctx.StopWithError(iris.StatusBadRequest, err) return } r.Pubkey = strings.ToLower(r.Pubkey) // Retrieve the account for the given email. account := model.Key{} if err := db.Get().Where("pubkey = ?", r.Pubkey).First(&account).Error; err != nil { a.Ctx.StopWithError(iris.StatusBadRequest, errors.New("invalid pubkey")) return } // Generate a random challenge string. challenge, err := generateAndSaveChallengeToken(account.AccountID, time.Minute) if err != nil { a.Ctx.StopWithError(iris.StatusInternalServerError, errors.New("failed to generate challenge")) return } // Return the challenge to the client. err = a.Ctx.JSON(&ChallengeResponse{Challenge: challenge}) if err != nil { panic(fmt.Errorf("Error with challenge request: %s \n", err)) } } // PostKeyLogin handles the POST /api/auth/pubkey/login request to authenticate a user using a public key challenge and return a JWT token. func (a *AuthController) PostPubkeyLogin() { var r PubkeyLoginRequest // Read the key login request from the client. if err := a.Ctx.ReadJSON(&r); err != nil { logger.Get().Debug("failed to parse request", zap.Error(err)) a.Ctx.StopWithError(iris.StatusBadRequest, err) return } r.Pubkey = strings.ToLower(r.Pubkey) r.Signature = strings.ToLower(r.Signature) // Retrieve the key challenge for the given challenge. challenge := model.KeyChallenge{} if err := db.Get().Where("challenge = ?", r.Challenge).First(&challenge).Error; err != nil { msg := "invalid key challenge" logger.Get().Debug(msg, zap.Error(err), zap.String("challenge", r.Challenge)) a.Ctx.StopWithError(iris.StatusBadRequest, errorx.RejectedOperation.New(msg)) return } verifiedToken, err := jwt.Verify(jwt.HS256, sharedKey, []byte(r.Challenge), blocklist) if err != nil { msg := "invalid key challenge" logger.Get().Debug(msg, zap.Error(err), zap.String("challenge", r.Challenge)) a.Ctx.StopWithError(iris.StatusBadRequest, errorx.RejectedOperation.New(msg)) return } rawPubKey, err := hex.DecodeString(r.Pubkey) if err != nil { msg := "invalid pubkey" logger.Get().Debug(msg, zap.Error(err), zap.String("pubkey", r.Pubkey)) a.Ctx.StopWithError(iris.StatusBadRequest, errorx.RejectedOperation.New(msg)) return } rawSignature, err := hex.DecodeString(r.Signature) if err != nil { msg := "invalid signature" logger.Get().Debug(msg, zap.Error(err), zap.String("signature", r.Signature)) a.Ctx.StopWithError(iris.StatusBadRequest, errorx.RejectedOperation.New(msg)) return } publicKeyDecoded := ed25519.PublicKey(rawPubKey) // Verify the challenge signature. if !ed25519.Verify(publicKeyDecoded, []byte(r.Challenge), rawSignature) { msg := "invalid challenge" logger.Get().Debug(msg, zap.Error(err), zap.String("challenge", r.Challenge)) a.Ctx.StopWithError(iris.StatusBadRequest, errorx.RejectedOperation.New(msg)) } // Generate a JWT token for the authenticated user. token, err := generateAndSaveLoginToken(challenge.AccountID, 24*time.Hour) if err != nil { a.Ctx.StopWithError(iris.StatusInternalServerError, errorx.RejectedOperation.Wrap(err, "failed to generate token")) return } err = blocklist.InvalidateToken(verifiedToken.Token, verifiedToken.StandardClaims) if err != nil { msg := "failed to invalidate token" logger.Get().Error(msg, zap.Error(err), zap.String("token", hex.EncodeToString(verifiedToken.Token))) a.Ctx.StopWithError(iris.StatusInternalServerError, errorx.RejectedOperation.Wrap(err, msg)) return } if err := db.Get().Delete(&challenge).Error; err != nil { msg := "failed to delete key challenge" logger.Get().Error(msg, zap.Error(err), zap.Any("key_challenge", challenge)) a.Ctx.StopWithError(iris.StatusBadRequest, errorx.RejectedOperation.New(msg)) return } // Return the JWT token to the client. err = a.Ctx.JSON(&LoginResponse{Token: token}) if err != nil { logger.Get().Error("failed to create response", zap.Error(err)) } } // PostLogout handles the POST /api/auth/logout request to invalidate a JWT token. func (a *AuthController) PostLogout() { var r LogoutRequest // Read the logout request from the client. if err := a.Ctx.ReadJSON(&r); err != nil { logger.Get().Debug("failed to parse request", zap.Error(err)) a.Ctx.StopWithError(iris.StatusBadRequest, err) return } // Verify the provided token. claims, err := jwt.Verify(jwt.HS256, sharedKey, []byte(r.Token), blocklist) if err != nil { msg := "invalid token" logger.Get().Debug(msg, zap.Error(err)) a.Ctx.StopWithError(iris.StatusBadRequest, errors.New(msg)) return } err = blocklist.InvalidateToken(claims.Token, claims.StandardClaims) if err != nil { msg := "failed to invalidate token" logger.Get().Error(msg, zap.Error(err), zap.String("token", hex.EncodeToString(claims.Token))) a.Ctx.StopWithError(iris.StatusBadRequest, errors.New(msg)) return } // Return a success response to the client. a.Ctx.StatusCode(iris.StatusNoContent) }