diff --git a/controller/account.go b/controller/account.go index 1da5bab..49699d5 100644 --- a/controller/account.go +++ b/controller/account.go @@ -1,32 +1,13 @@ package controller import ( - "errors" "git.lumeweb.com/LumeWeb/portal/controller/request" - "git.lumeweb.com/LumeWeb/portal/db" - "git.lumeweb.com/LumeWeb/portal/logger" - "git.lumeweb.com/LumeWeb/portal/model" + "git.lumeweb.com/LumeWeb/portal/service/account" "github.com/kataras/iris/v12" - "go.uber.org/zap" - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" - "strings" ) type AccountController struct { - Ctx iris.Context -} - -func hashPassword(password string) (string, error) { - // Generate a new bcrypt hash from the provided password. - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - logger.Get().Error("failed to hash password", zap.Error(err)) - return "", err - } - - // Convert the hashed password to a string and return it. - return string(hashedPassword), nil + Controller } func (a *AccountController) PostRegister() { @@ -37,75 +18,15 @@ func (a *AccountController) PostRegister() { r, _ := ri.(*request.RegisterRequest) - // Check if an account with the same email address already exists. - existingAccount := model.Account{} - err := db.Get().Where("email = ?", r.Email).First(&existingAccount).Error - if err == nil { - logger.Get().Debug("account with email already exists", zap.Error(err), zap.String("email", r.Email)) - // An account with the same email address already exists. - // Return an error response to the client. - a.Ctx.StopWithError(iris.StatusConflict, errors.New("an account with this email address already exists")) - return - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - logger.Get().Error("error querying accounts", zap.Error(err), zap.String("email", r.Email)) - // An unexpected error occurred while querying the database. - // Return an error response to the client. - a.Ctx.StopWithError(iris.StatusInternalServerError, err) - return - } + err := account.Register(r.Email, r.Password, r.Pubkey) - if len(r.Pubkey) > 0 { - r.Pubkey = strings.ToLower(r.Pubkey) - var count int64 - err := db.Get().Model(&model.Key{}).Where("pubkey = ?", r.Pubkey).Count(&count).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - logger.Get().Error("error querying accounts", zap.Error(err), zap.String("pubkey", r.Pubkey)) - a.Ctx.StopWithError(iris.StatusInternalServerError, err) - } - if count > 0 { - logger.Get().Debug("account with pubkey already exists", zap.Error(err), zap.String("pubkey", r.Pubkey)) - // An account with the same pubkey already exists. - // Return an error response to the client. - a.Ctx.StopWithError(iris.StatusConflict, errors.New("an account with this pubkey already exists")) - return - } - - } - - // Create a new Account model with the provided email and hashed password. - account := model.Account{ - Email: r.Email, - } - - // Hash the password before saving it to the database. - if len(r.Password) > 0 { - hashedPassword, err := hashPassword(r.Password) - if err != nil { - a.Ctx.StopWithError(iris.StatusInternalServerError, err) - return - } - - account.Password = &hashedPassword - } - - err = db.Get().Transaction(func(tx *gorm.DB) error { - // do some database operations in the transaction (use 'tx' from this point, not 'db') - if err := tx.Create(&account).Error; err != nil { - return err - } - - if len(r.Pubkey) > 0 { - if err := tx.Create(&model.Key{Account: account, Pubkey: strings.ToLower(r.Pubkey)}).Error; err != nil { - return err - } - } - - // return nil will commit the whole transaction - return nil - }) if err != nil { - logger.Get().Error("failed to create account", zap.Error(err)) - a.Ctx.StopWithError(iris.StatusInternalServerError, err) + if err == account.ErrQueryingAcct || err == account.ErrFailedCreateAccount { + a.Ctx.StopWithError(iris.StatusInternalServerError, err) + } else { + a.Ctx.StopWithError(iris.StatusBadRequest, err) + } + return } diff --git a/controller/auth.go b/controller/auth.go index 512a28a..e25c9b0 100644 --- a/controller/auth.go +++ b/controller/auth.go @@ -1,119 +1,14 @@ package controller import ( - "crypto/ed25519" - "encoding/hex" - "errors" - "fmt" "git.lumeweb.com/LumeWeb/portal/controller/request" "git.lumeweb.com/LumeWeb/portal/controller/response" - "git.lumeweb.com/LumeWeb/portal/db" - "git.lumeweb.com/LumeWeb/portal/logger" - "git.lumeweb.com/LumeWeb/portal/model" - "github.com/joomcode/errorx" + "git.lumeweb.com/LumeWeb/portal/service/auth" "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 -} - -// 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 + Controller } // PostLogin handles the POST /api/auth/login request to authenticate a user and return a JWT token. @@ -125,43 +20,18 @@ func (a *AuthController) PostLogin() { r, _ := ri.(*request.LoginRequest) - // 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 - } + token, err := auth.LoginWithPassword(r.Email, r.Password) - 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)) + if err == auth.ErrFailedGenerateToken { + a.Ctx.StopWithError(iris.StatusInternalServerError, err) + } else { + a.Ctx.StopWithError(iris.StatusUnauthorized, err) + } return } - // Return the JWT token to the client. - err = a.Ctx.JSON(&response.LoginResponse{Token: token}) - if err != nil { - logger.Get().Error("failed to generate response", zap.Error(err)) - } + a.respondJSON(&response.LoginResponse{Token: token}) } // PostChallenge handles the POST /api/auth/pubkey/challenge request to generate a challenge for a user's public key. @@ -173,27 +43,17 @@ func (a *AuthController) PostPubkeyChallenge() { r, _ := (ri).(*request.PubkeyChallengeRequest) - 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")) + challenge, err := auth.GeneratePubkeyChallenge(r.Pubkey) + if err != nil { + if err == auth.ErrFailedGenerateKeyChallenge { + a.Ctx.StopWithError(iris.StatusInternalServerError, err) + } else { + a.Ctx.StopWithError(iris.StatusUnauthorized, err) + } 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(&response.ChallengeResponse{Challenge: challenge}) - if err != nil { - logger.Get().Error("failed to create response", zap.Error(err)) - } + a.respondJSON(&response.ChallengeResponse{Challenge: challenge}) } // PostKeyLogin handles the POST /api/auth/pubkey/login request to authenticate a user using a public key challenge and return a JWT token. @@ -205,78 +65,18 @@ func (a *AuthController) PostPubkeyLogin() { r, _ := ri.(*request.PubkeyLoginRequest) - r.Pubkey = strings.ToLower(r.Pubkey) - r.Signature = strings.ToLower(r.Signature) + token, err := auth.LoginWithPubkey(r.Pubkey, r.Challenge, 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 := fmt.Sprintf("invalid key challenge: %s", err.Error()) - logger.Get().Debug(msg, zap.Error(err), zap.String("challenge", r.Challenge)) - a.Ctx.StopWithError(iris.StatusBadRequest, errorx.RejectedOperation.New(msg)) + if err == auth.ErrFailedGenerateKeyChallenge || err == auth.ErrFailedGenerateToken || err == auth.ErrFailedSaveToken { + a.Ctx.StopWithError(iris.StatusInternalServerError, err) + } else { + a.Ctx.StopWithError(iris.StatusUnauthorized, err) + } return } - rawPubKey, err := hex.DecodeString(r.Pubkey) - if err != nil { - msg := fmt.Sprintf("invalid pubkey: %s", err.Error()) - 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 := fmt.Sprintf("invalid signature: %s", err.Error()) - 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(&response.LoginResponse{Token: token}) - if err != nil { - logger.Get().Error("failed to create response", zap.Error(err)) - } + a.respondJSON(&response.LoginResponse{Token: token}) } @@ -289,20 +89,10 @@ func (a *AuthController) PostLogout() { r, _ := ri.(*request.LogoutRequest) - // 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 := auth.Logout(r.Token) - 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)) + a.Ctx.StopWithError(iris.StatusBadRequest, err) return } diff --git a/controller/controller.go b/controller/controller.go index 6aa6fd3..7ad659c 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -59,3 +59,14 @@ func internalErrorCustom(ctx iris.Context, err error, customError error) bool { func sendError(ctx iris.Context, err error, irisError int) bool { return sendErrorCustom(ctx, err, nil, irisError) } + +type Controller struct { + Ctx iris.Context +} + +func (c Controller) respondJSON(data interface{}) { + err := c.Ctx.JSON(data) + if err != nil { + logger.Get().Error("failed to generate response", zap.Error(err)) + } +} diff --git a/jwt/jwt.go b/jwt/jwt.go deleted file mode 100644 index c26309d..0000000 --- a/jwt/jwt.go +++ /dev/null @@ -1,20 +0,0 @@ -package jwt - -import ( - "github.com/kataras/iris/v12/middleware/jwt" - _ "github.com/kataras/iris/v12/middleware/jwt" -) - -var ( - Secret = []byte("signature_hmac_secret_shared_key") - v *jwt.Verifier -) - -func init() { - v = jwt.NewVerifier(jwt.HS256, Secret) - v.WithDefaultBlocklist() -} - -func Get() *jwt.Verifier { - return v -} diff --git a/main.go b/main.go index 99d5edb..6ca5dc7 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "git.lumeweb.com/LumeWeb/portal/db" _ "git.lumeweb.com/LumeWeb/portal/docs" "git.lumeweb.com/LumeWeb/portal/logger" + "git.lumeweb.com/LumeWeb/portal/service/auth" "git.lumeweb.com/LumeWeb/portal/service/files" "git.lumeweb.com/LumeWeb/portal/tus" "github.com/iris-contrib/swagger" @@ -42,9 +43,9 @@ func main() { // Initialize the database connection db.Init() - logger.Init() files.Init() + auth.Init() // Create a new Iris app instance app := iris.New() @@ -68,6 +69,7 @@ func main() { mvc.Configure(v1.Party("/files"), func(app *mvc.Application) { app.Handle(new(controller.FilesController)) + app.Router.Use() }) tusHandler := tus.Init() diff --git a/service/account/account.go b/service/account/account.go new file mode 100644 index 0000000..4c4d3ff --- /dev/null +++ b/service/account/account.go @@ -0,0 +1,88 @@ +package account + +import ( + "errors" + "git.lumeweb.com/LumeWeb/portal/db" + "git.lumeweb.com/LumeWeb/portal/logger" + "git.lumeweb.com/LumeWeb/portal/model" + "go.uber.org/zap" + "gorm.io/gorm" + "strings" +) + +var ( + ErrEmailExists = errors.New("Account with email already exists") + ErrPubkeyExists = errors.New("Account with pubkey already exists") + ErrQueryingAcct = errors.New("Error querying accounts") + ErrFailedHashPassword = errors.New("Failed to hash password") + ErrFailedCreateAccount = errors.New("Failed to create account") +) + +func Register(email string, password string, pubkey string) error { + // Check if an account with the same email address already exists. + existingAccount := model.Account{} + err := db.Get().Where("email = ?", email).First(&existingAccount).Error + if err == nil { + logger.Get().Debug(ErrEmailExists.Error(), zap.Error(err), zap.String("email", email)) + // An account with the same email address already exists. + // Return an error response to the client. + return ErrEmailExists + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + logger.Get().Error(ErrQueryingAcct.Error(), zap.Error(err)) + return ErrQueryingAcct + } + + if len(pubkey) > 0 { + pubkey = strings.ToLower(pubkey) + var count int64 + err := db.Get().Model(&model.Key{}).Where("pubkey = ?", pubkey).Count(&count).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + logger.Get().Error(ErrQueryingAcct.Error(), zap.Error(err), zap.String("pubkey", pubkey)) + return ErrQueryingAcct + } + if count > 0 { + logger.Get().Debug(ErrPubkeyExists.Error(), zap.Error(err), zap.String("pubkey", pubkey)) + // An account with the same pubkey already exists. + // Return an error response to the client. + return ErrPubkeyExists + } + + } + + // Create a new Account model with the provided email and hashed password. + account := model.Account{ + Email: email, + } + + // Hash the password before saving it to the database. + if len(password) > 0 { + hashedPassword, err := hashPassword(password) + if err != nil { + return err + } + + account.Password = &hashedPassword + } + + err = db.Get().Transaction(func(tx *gorm.DB) error { + // do some database operations in the transaction (use 'tx' from this point, not 'db') + if err := tx.Create(&account).Error; err != nil { + return err + } + + if len(pubkey) > 0 { + if err := tx.Create(&model.Key{Account: account, Pubkey: pubkey}).Error; err != nil { + return err + } + } + + // return nil will commit the whole transaction + return nil + }) + if err != nil { + logger.Get().Error(ErrFailedCreateAccount.Error(), zap.Error(err)) + return ErrFailedCreateAccount + } + + return nil +} diff --git a/service/account/util.go b/service/account/util.go new file mode 100644 index 0000000..95d3a33 --- /dev/null +++ b/service/account/util.go @@ -0,0 +1,19 @@ +package account + +import ( + "git.lumeweb.com/LumeWeb/portal/logger" + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" +) + +func hashPassword(password string) (string, error) { + // Generate a new bcrypt hash from the provided password. + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + logger.Get().Error(ErrFailedHashPassword.Error(), zap.Error(err)) + return "", ErrFailedHashPassword + } + + // Convert the hashed password to a string and return it. + return string(hashedPassword), nil +} diff --git a/service/auth/auth.go b/service/auth/auth.go new file mode 100644 index 0000000..1b8cc65 --- /dev/null +++ b/service/auth/auth.go @@ -0,0 +1,183 @@ +package auth + +import ( + "crypto/ed25519" + "encoding/hex" + "errors" + "git.lumeweb.com/LumeWeb/portal/db" + "git.lumeweb.com/LumeWeb/portal/logger" + "git.lumeweb.com/LumeWeb/portal/model" + "github.com/kataras/jwt" + "go.uber.org/zap" + "strings" + "time" +) + +var sharedKey = []byte("sercrethatmaycontainch@r$32chars") + +var blocklist *jwt.Blocklist + +var ( + ErrInvalidEmailPassword = errors.New("Invalid email or password") + ErrPubkeyOnly = errors.New("Only pubkey login is supported") + ErrFailedGenerateToken = errors.New("Failed to generate token") + ErrFailedGenerateKeyChallenge = errors.New("Failed to generate key challenge") + ErrFailedSignJwt = errors.New("Failed to sign jwt") + ErrFailedSaveToken = errors.New("Failed to sign token") + ErrFailedDeleteKeyChallenge = errors.New("Failed to delete key challenge") + ErrFailedInvalidateToken = errors.New("Failed to invalidate token") + ErrInvalidKeyChallenge = errors.New("Invalid key challenge") + ErrInvalidPubkey = errors.New("Invalid pubkey") + ErrInvalidSignature = errors.New("Invalid signature") + ErrInvalidToken = errors.New("Invalid token") +) + +func Init() { + blocklist = jwt.NewBlocklist(0) +} + +func LoginWithPassword(email string, password string) (string, error) { + // Retrieve the account for the given email. + account := model.Account{} + if err := db.Get().Model(&account).Where("email = ?", email).First(&account).Error; err != nil { + logger.Get().Debug(ErrInvalidEmailPassword.Error(), zap.String("email", email)) + return "", ErrInvalidEmailPassword + } + + if account.Password == nil || len(*account.Password) == 0 { + logger.Get().Debug(ErrPubkeyOnly.Error(), zap.String("email", email)) + return "", ErrPubkeyOnly + } + + // Verify the provided password against the hashed password stored in the database. + if err := verifyPassword(*account.Password, password); err != nil { + logger.Get().Debug(ErrPubkeyOnly.Error(), zap.String("email", email)) + return "", ErrInvalidEmailPassword + } + + // Generate a JWT token for the authenticated user. + token, err := generateAndSaveLoginToken(account.ID, 24*time.Hour) + if err != nil { + return "", err + } + + return token, nil +} + +func LoginWithPubkey(pubkey string, challenge string, signature string) (string, error) { + pubkey = strings.ToLower(pubkey) + signature = strings.ToLower(signature) + + // Retrieve the key challenge for the given challenge. + challengeObj := model.KeyChallenge{} + if err := db.Get().Model(challengeObj).Where("challenge = ?", challenge).First(&challengeObj).Error; err != nil { + logger.Get().Debug(ErrInvalidKeyChallenge.Error(), zap.Error(err), zap.String("challenge", challenge)) + return "", ErrInvalidKeyChallenge + } + + verifiedToken, err := jwt.Verify(jwt.HS256, sharedKey, []byte(challenge), blocklist) + if err != nil { + logger.Get().Debug(ErrInvalidKeyChallenge.Error(), zap.Error(err), zap.String("challenge", challenge)) + return "", ErrInvalidKeyChallenge + } + + rawPubKey, err := hex.DecodeString(pubkey) + if err != nil { + logger.Get().Debug(ErrInvalidPubkey.Error(), zap.Error(err), zap.String("pubkey", pubkey)) + return "", ErrInvalidPubkey + } + + rawSignature, err := hex.DecodeString(signature) + if err != nil { + logger.Get().Debug(ErrInvalidPubkey.Error(), zap.Error(err), zap.String("signature", pubkey)) + return "", ErrInvalidSignature + } + + publicKeyDecoded := ed25519.PublicKey(rawPubKey) + + // Verify the challenge signature. + if !ed25519.Verify(publicKeyDecoded, []byte(challenge), rawSignature) { + logger.Get().Debug(ErrInvalidKeyChallenge.Error(), zap.Error(err), zap.String("challenge", challenge)) + return "", ErrInvalidKeyChallenge + } + + // Generate a JWT token for the authenticated user. + token, err := generateAndSaveLoginToken(challengeObj.AccountID, 24*time.Hour) + if err != nil { + return "", err + } + + err = blocklist.InvalidateToken(verifiedToken.Token, verifiedToken.StandardClaims) + if err != nil { + logger.Get().Error(ErrFailedInvalidateToken.Error(), zap.Error(err), zap.String("pubkey", pubkey), zap.ByteString("token", verifiedToken.Token), zap.String("challenge", challenge)) + return "", ErrFailedInvalidateToken + } + + if err := db.Get().Delete(&challenge).Error; err != nil { + logger.Get().Debug(ErrFailedDeleteKeyChallenge.Error(), zap.Error(err)) + return "", ErrFailedDeleteKeyChallenge + } + + return token, nil +} + +func GeneratePubkeyChallenge(pubkey string) (string, error) { + pubkey = strings.ToLower(pubkey) + + // Retrieve the account for the given email. + account := model.Key{} + if err := db.Get().Where("pubkey = ?", pubkey).First(&account).Error; err != nil { + logger.Get().Debug("failed to query pubkey", zap.Error(err)) + return "", errors.New("invalid pubkey") + } + + // Generate a random challenge string. + challenge, err := generateAndSaveChallengeToken(account.AccountID, time.Minute) + if err != nil { + logger.Get().Error(ErrFailedGenerateKeyChallenge.Error()) + return "", ErrFailedGenerateKeyChallenge + } + + return challenge, nil +} + +func Logout(token string) error { + // Verify the provided token. + claims, err := jwt.Verify(jwt.HS256, sharedKey, []byte(token), blocklist) + if err != nil { + logger.Get().Debug(ErrInvalidToken.Error(), zap.Error(err)) + return ErrInvalidToken + } + + err = blocklist.InvalidateToken(claims.Token, claims.StandardClaims) + if err != nil { + logger.Get().Error(ErrFailedInvalidateToken.Error(), zap.Error(err), zap.String("token", token)) + return ErrFailedInvalidateToken + } + + // Retrieve the key challenge for the given challenge. + session := model.LoginSession{} + if err := db.Get().Model(session).Where("token = ?", token).First(&session).Error; err != nil { + logger.Get().Debug(ErrFailedInvalidateToken.Error(), zap.Error(err), zap.String("token", token)) + return ErrFailedInvalidateToken + } + + db.Get().Delete(&session) + + return nil +} + +func VerifyLoginToken(token string) error { + _, err := jwt.Verify(jwt.HS256, sharedKey, []byte(token), blocklist) + if err != nil { + return err + } + + session := model.LoginSession{} + if err := db.Get().Model(session).Where("token = ?", token).First(&session).Error; err != nil { + logger.Get().Debug(ErrInvalidToken.Error(), zap.Error(err), zap.String("token", token)) + return ErrInvalidToken + } + + return nil +} diff --git a/service/auth/util.go b/service/auth/util.go new file mode 100644 index 0000000..2ed9aad --- /dev/null +++ b/service/auth/util.go @@ -0,0 +1,112 @@ +package auth + +import ( + "errors" + "git.lumeweb.com/LumeWeb/portal/db" + "git.lumeweb.com/LumeWeb/portal/logger" + "git.lumeweb.com/LumeWeb/portal/model" + "github.com/kataras/iris/v12" + "github.com/kataras/jwt" + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" + "strings" + "time" +) + +// 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(ErrFailedSignJwt.Error(), 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(ErrFailedGenerateToken.Error()) + return "", ErrFailedGenerateToken + } + + 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 { + logger.Get().Error(ErrFailedSaveToken.Error(), zap.Uint("account_id", accountID), zap.Duration("max_age", maxAge)) + return "", ErrFailedSaveToken + } + + 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(ErrFailedGenerateToken.Error(), zap.Error(err)) + return "", ErrFailedGenerateToken + } + + 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 { + logger.Get().Error(ErrFailedSaveToken.Error(), zap.Error(err)) + return "", ErrFailedSaveToken + } + + return token, nil +} + +func GetRequestAuthCode(ctx iris.Context) string { + authHeader := ctx.GetHeader("Authorization") + if authHeader == "" { + return "" + } + + // pure check: authorization header format must be Bearer {token} + authHeaderParts := strings.Split(authHeader, " ") + if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" { + return "" + } + + return authHeaderParts[1] +}