diff --git a/go.mod b/go.mod index ad4ef4d..6a9344f 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.6.0 github.com/json-iterator/go v1.1.12 - github.com/nicksnyder/go-i18n/v2 v2.4.0 github.com/robfig/cron/v3 v3.0.1 github.com/rs/zerolog v1.31.0 github.com/samber/lo v1.39.0 diff --git a/go.sum b/go.sum index bde1107..1a03dcb 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,5 @@ -git.solsynth.dev/hydrogen/dealer v0.0.0-20240823113302-208da7e90fdb h1:dv4uVDMe53eBprW2Q8ocAhZuO+DKWlWyxGiJMiwE62E= -git.solsynth.dev/hydrogen/dealer v0.0.0-20240823113302-208da7e90fdb/go.mod h1:Q51JPkKnV0UoOT/IRmdBh5CyfSlp7s8BRGzgooYHqkI= -git.solsynth.dev/hydrogen/dealer v0.0.0-20240824155914-68c6a5565468 h1:DZ1b5WA1FoUE71zyl6OzQ+QbCo4tPJv077ekM1VQ524= -git.solsynth.dev/hydrogen/dealer v0.0.0-20240824155914-68c6a5565468/go.mod h1:Q51JPkKnV0UoOT/IRmdBh5CyfSlp7s8BRGzgooYHqkI= git.solsynth.dev/hydrogen/dealer v0.0.0-20240911145828-d734d617bfc8 h1:kWheneSdSySG5tz9TAXrtr546JdMpQZTyWDFk4jeGwg= git.solsynth.dev/hydrogen/dealer v0.0.0-20240911145828-d734d617bfc8/go.mod h1:Q51JPkKnV0UoOT/IRmdBh5CyfSlp7s8BRGzgooYHqkI= -github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= -github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -211,8 +205,6 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM= -github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= @@ -405,8 +397,6 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/internal/models/auth.go b/pkg/internal/models/auth.go index 64fa958..5fb4926 100644 --- a/pkg/internal/models/auth.go +++ b/pkg/internal/models/auth.go @@ -28,28 +28,28 @@ type AuthFactor struct { type AuthTicket struct { BaseModel - Location string `json:"location"` - IpAddress string `json:"ip_address"` - UserAgent string `json:"user_agent"` - RequireMFA bool `json:"require_mfa"` - RequireAuthenticate bool `json:"require_authenticate"` - Claims datatypes.JSONSlice[string] `json:"claims"` - Audiences datatypes.JSONSlice[string] `json:"audiences"` - GrantToken *string `json:"grant_token"` - AccessToken *string `json:"access_token"` - RefreshToken *string `json:"refresh_token"` - ExpiredAt *time.Time `json:"expired_at"` - AvailableAt *time.Time `json:"available_at"` - LastGrantAt *time.Time `json:"last_grant_at"` - Nonce *string `json:"nonce"` - ClientID *uint `json:"client_id"` + Location string `json:"location"` + IpAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + StepRemain int `json:"step_remain"` + Claims datatypes.JSONSlice[string] `json:"claims"` + Audiences datatypes.JSONSlice[string] `json:"audiences"` + FactorTrail datatypes.JSONSlice[int] `json:"factor_trail"` + GrantToken *string `json:"grant_token"` + AccessToken *string `json:"access_token"` + RefreshToken *string `json:"refresh_token"` + ExpiredAt *time.Time `json:"expired_at"` + AvailableAt *time.Time `json:"available_at"` + LastGrantAt *time.Time `json:"last_grant_at"` + Nonce *string `json:"nonce"` + ClientID *uint `json:"client_id"` Account Account `json:"account"` AccountID uint `json:"account_id"` } func (v AuthTicket) IsAvailable() error { - if v.RequireMFA || v.RequireAuthenticate { + if v.StepRemain > 0 { return fmt.Errorf("ticket isn't authenticated yet") } if v.AvailableAt != nil && time.Now().Unix() < v.AvailableAt.Unix() { @@ -62,6 +62,14 @@ func (v AuthTicket) IsAvailable() error { return nil } +func (v AuthTicket) IsCanBeAvailble() error { + if v.StepRemain > 0 { + return fmt.Errorf("ticket isn't authenticated yet") + } + + return nil +} + type AuthContext struct { Ticket AuthTicket `json:"ticket"` Account Account `json:"account"` diff --git a/pkg/internal/server/api/auth_api.go b/pkg/internal/server/api/auth_api.go index 2fb5c95..3e51d2d 100644 --- a/pkg/internal/server/api/auth_api.go +++ b/pkg/internal/server/api/auth_api.go @@ -28,7 +28,6 @@ func getTicket(c *fiber.Ctx) error { func doAuthenticate(c *fiber.Ctx) error { var data struct { Username string `json:"username" validate:"required"` - Password string `json:"password" validate:"required"` } if err := exts.BindAndValidate(c, &data); err != nil { @@ -39,7 +38,7 @@ func doAuthenticate(c *fiber.Ctx) error { if err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("account was not found: %v", err.Error())) } else if user.ConfirmedAt == nil { - return fiber.NewError(fiber.StatusForbidden, "account was not confirmed") + return fiber.NewError(fiber.StatusForbidden, "account was not confirmed; check your inbox, there will be an email lead you confirm your registration") } else if user.SuspendedAt != nil { return fiber.NewError(fiber.StatusForbidden, "account was suspended") } @@ -49,18 +48,13 @@ func doAuthenticate(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable setup ticket: %v", err.Error())) } - ticket, err = services.ActiveTicketWithPassword(ticket, data.Password) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("failed to authenticate: %v", err.Error())) - } - return c.JSON(fiber.Map{ "is_finished": ticket.IsAvailable() == nil, "ticket": ticket, }) } -func doMultiFactorAuthenticate(c *fiber.Ctx) error { +func doAuthTicketCheck(c *fiber.Ctx) error { var data struct { TicketID uint `json:"ticket_id" validate:"required"` FactorID uint `json:"factor_id" validate:"required"` @@ -81,7 +75,7 @@ func doMultiFactorAuthenticate(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("factor was not found: %v", err.Error())) } - ticket, err = services.ActiveTicketWithMFA(ticket, factor, data.Code) + ticket, err = services.PerformTicketCheck(ticket, factor, data.Code) if err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("failed to authenticate: %v", err.Error())) } diff --git a/pkg/internal/server/api/index.go b/pkg/internal/server/api/index.go index 3fa6beb..f053b6c 100644 --- a/pkg/internal/server/api/index.go +++ b/pkg/internal/server/api/index.go @@ -76,7 +76,7 @@ func MapAPIs(app *fiber.App, baseURL string) { auth := api.Group("/auth").Name("Auth") { auth.Post("/", doAuthenticate) - auth.Post("/mfa", doMultiFactorAuthenticate) + auth.Patch("/", doAuthTicketCheck) auth.Post("/token", getToken) auth.Get("/tickets/:ticketId", getTicket) diff --git a/pkg/internal/services/bot_token.go b/pkg/internal/services/bot_token.go index 26ab6b9..0ce76e6 100644 --- a/pkg/internal/services/bot_token.go +++ b/pkg/internal/services/bot_token.go @@ -1,11 +1,12 @@ package services import ( + "time" + "git.solsynth.dev/hydrogen/passport/pkg/internal/database" "git.solsynth.dev/hydrogen/passport/pkg/internal/models" "github.com/google/uuid" "github.com/samber/lo" - "time" ) func NewApiKey(user models.Account, key models.ApiKey, ip, ua string, claims []string) (models.ApiKey, error) { @@ -18,19 +19,18 @@ func NewApiKey(user models.Account, key models.ApiKey, ip, ua string, claims []s } key.Ticket = models.AuthTicket{ - IpAddress: ip, - UserAgent: ua, - RequireMFA: false, - RequireAuthenticate: false, - Claims: claims, - Audiences: []string{InternalTokenAudience}, - GrantToken: lo.ToPtr(uuid.NewString()), - AccessToken: lo.ToPtr(uuid.NewString()), - RefreshToken: lo.ToPtr(uuid.NewString()), - AvailableAt: lo.ToPtr(time.Now()), - ExpiredAt: expiredAt, - Account: user, - AccountID: user.ID, + IpAddress: ip, + UserAgent: ua, + StepRemain: 0, + Claims: claims, + Audiences: []string{InternalTokenAudience}, + GrantToken: lo.ToPtr(uuid.NewString()), + AccessToken: lo.ToPtr(uuid.NewString()), + RefreshToken: lo.ToPtr(uuid.NewString()), + AvailableAt: lo.ToPtr(time.Now()), + ExpiredAt: expiredAt, + Account: user, + AccountID: user.ID, } if err := database.C.Save(&key).Error; err != nil { diff --git a/pkg/internal/services/ticket.go b/pkg/internal/services/ticket.go index f52bc14..6f407dd 100644 --- a/pkg/internal/services/ticket.go +++ b/pkg/internal/services/ticket.go @@ -2,9 +2,10 @@ package services import ( "fmt" - "github.com/rs/zerolog/log" "time" + "github.com/rs/zerolog/log" + "github.com/google/uuid" "git.solsynth.dev/hydrogen/passport/pkg/internal/database" @@ -14,7 +15,9 @@ import ( const InternalTokenAudience = "solar-network" -func DetectRisk(user models.Account, ip, ua string) bool { +// DetectRisk is used for detect user environment is suitable for no multi-factor authenticate or not. +// Return the remaining steps, value is from 1 to 2, may appear 3 if user enabled the third-authentication-factor. +func DetectRisk(user models.Account, ip, ua string) int { var clue int64 if err := database.C. Where(models.AuthTicket{AccountID: user.ID, IpAddress: ip}). @@ -22,36 +25,47 @@ func DetectRisk(user models.Account, ip, ua string) bool { Model(models.AuthTicket{}). Count(&clue).Error; err == nil { if clue >= 1 { - return false + return 1 } } - return true + return 2 +} + +// PickTicketAttempt is trying to pick up the ticket that haven't completed but created by a same client (identify by ip address). +// Then the client can continue their journey to get ticket actived. +func PickTicketAttempt(user models.Account, ip string) (models.AuthTicket, error) { + var ticket models.AuthTicket + if err := database.C. + Where("account_id = ? AND ip_address = ? AND expired_at < ? AND available_at IS NULL", user.ID, ip, time.Now()). + First(&ticket).Error; err != nil { + return ticket, err + } + return ticket, nil } func NewTicket(user models.Account, ip, ua string) (models.AuthTicket, error) { var ticket models.AuthTicket - if err := database.C. - Where("account_id = ? AND expired_at < ? AND available_at IS NULL", time.Now(), user.ID). - First(&ticket).Error; err == nil { + if ticket, err := PickTicketAttempt(user, ip); err == nil { return ticket, nil } - requireMFA := DetectRisk(user, ip, ua) - if count := CountUserFactor(user.ID); count <= 1 { - requireMFA = false + steps := DetectRisk(user, ip, ua) + if count := CountUserFactor(user.ID); count <= 0 { + return ticket, fmt.Errorf("specified user didn't enable sign in") + } else { + steps = min(steps, int(count)) } ticket = models.AuthTicket{ - Claims: []string{"*"}, - Audiences: []string{InternalTokenAudience}, - IpAddress: ip, - UserAgent: ua, - RequireMFA: requireMFA, - RequireAuthenticate: true, - ExpiredAt: nil, - AvailableAt: nil, - AccountID: user.ID, + Claims: []string{"*"}, + Audiences: []string{InternalTokenAudience}, + IpAddress: ip, + UserAgent: ua, + StepRemain: steps, + ExpiredAt: nil, + AvailableAt: nil, + AccountID: user.ID, } err := database.C.Save(&ticket).Error @@ -91,27 +105,17 @@ func NewOauthTicket( return ticket, nil } -func ActiveTicketWithPassword(ticket models.AuthTicket, password string) (models.AuthTicket, error) { +func ActiveTicket(ticket models.AuthTicket) (models.AuthTicket, error) { if ticket.AvailableAt != nil { return ticket, nil - } else if !ticket.RequireAuthenticate { - return ticket, nil - } - - if factor, err := GetPasswordTypeFactor(ticket.AccountID); err != nil { - return ticket, fmt.Errorf("unable to active ticket: %v", err) - } else if err = CheckFactor(factor, password); err != nil { + } else if err := ticket.IsCanBeAvailble(); err != nil { return ticket, err } - ticket.RequireAuthenticate = false - - if !ticket.RequireAuthenticate && !ticket.RequireMFA { - ticket.AvailableAt = lo.ToPtr(time.Now()) - ticket.GrantToken = lo.ToPtr(uuid.NewString()) - ticket.AccessToken = lo.ToPtr(uuid.NewString()) - ticket.RefreshToken = lo.ToPtr(uuid.NewString()) - } + ticket.AvailableAt = lo.ToPtr(time.Now()) + ticket.GrantToken = lo.ToPtr(uuid.NewString()) + ticket.AccessToken = lo.ToPtr(uuid.NewString()) + ticket.RefreshToken = lo.ToPtr(uuid.NewString()) if err := database.C.Save(&ticket).Error; err != nil { return ticket, err @@ -120,28 +124,59 @@ func ActiveTicketWithPassword(ticket models.AuthTicket, password string) (models return ticket, nil } -func ActiveTicketWithMFA(ticket models.AuthTicket, factor models.AuthFactor, code string) (models.AuthTicket, error) { +func ActiveTicketWithPassword(ticket models.AuthTicket, password string) (models.AuthTicket, error) { if ticket.AvailableAt != nil { return ticket, nil - } else if !ticket.RequireMFA { + } else if ticket.StepRemain == 1 { + return ticket, fmt.Errorf("multi-factor authentication required") + } + + factor, err := GetPasswordTypeFactor(ticket.AccountID) + if err != nil { + return ticket, fmt.Errorf("unable to authenticate, password factor was not found: %v", err) + } else if err := CheckFactor(factor, password); err != nil { + return ticket, fmt.Errorf("invalid password: %v", err) + } + + ticket.StepRemain-- + ticket.FactorTrail = append(ticket.FactorTrail, int(factor.ID)) + + ticket.AvailableAt = lo.ToPtr(time.Now()) + ticket.GrantToken = lo.ToPtr(uuid.NewString()) + ticket.AccessToken = lo.ToPtr(uuid.NewString()) + ticket.RefreshToken = lo.ToPtr(uuid.NewString()) + + if err := database.C.Save(&ticket).Error; err != nil { + return ticket, err + } + + return ticket, nil +} + +func PerformTicketCheck(ticket models.AuthTicket, factor models.AuthFactor, code string) (models.AuthTicket, error) { + if ticket.AvailableAt != nil { return ticket, nil + } else if ticket.StepRemain <= 0 { + return ticket, nil + } + + if lo.Contains(ticket.FactorTrail, int(factor.ID)) { + return ticket, fmt.Errorf("already checked this ticket with factor %d", factor.ID) } if err := CheckFactor(factor, code); err != nil { return ticket, fmt.Errorf("invalid code: %v", err) } - ticket.RequireMFA = false + ticket.StepRemain-- + ticket.FactorTrail = append(ticket.FactorTrail, int(factor.ID)) - if !ticket.RequireAuthenticate && !ticket.RequireMFA { - ticket.AvailableAt = lo.ToPtr(time.Now()) - ticket.GrantToken = lo.ToPtr(uuid.NewString()) - ticket.AccessToken = lo.ToPtr(uuid.NewString()) - ticket.RefreshToken = lo.ToPtr(uuid.NewString()) - } - - if err := database.C.Save(&ticket).Error; err != nil { - return ticket, err + if ticket.IsCanBeAvailble() == nil { + return ActiveTicket(ticket) + } else { + if err := database.C.Save(&ticket).Error; err != nil { + return ticket, err + } } return ticket, nil diff --git a/pkg/main.go b/pkg/main.go index 217b234..0b21900 100644 --- a/pkg/main.go +++ b/pkg/main.go @@ -1,12 +1,13 @@ package main import ( - "git.solsynth.dev/hydrogen/passport/pkg/internal" - "git.solsynth.dev/hydrogen/passport/pkg/internal/gap" "os" "os/signal" "syscall" + pkg "git.solsynth.dev/hydrogen/passport/pkg/internal" + "git.solsynth.dev/hydrogen/passport/pkg/internal/gap" + "git.solsynth.dev/hydrogen/passport/pkg/internal/grpc" "git.solsynth.dev/hydrogen/passport/pkg/internal/server" "git.solsynth.dev/hydrogen/passport/pkg/internal/services" @@ -58,7 +59,7 @@ func main() { quartz.AddFunc("@every 60m", services.DoAutoSignoff) quartz.AddFunc("@every 60m", services.DoAutoDatabaseCleanup) quartz.AddFunc("@every 60s", services.RecycleAuthContext) - quartz.AddFunc("@every 60m", services.RecycleUnConfirmAccount) + quartz.AddFunc("@midnight", services.RecycleUnConfirmAccount) quartz.AddFunc("@every 60s", services.SaveEventChanges) quartz.Start()