New ticket ways

This commit is contained in:
2024-04-20 19:04:33 +08:00
parent 0d78f34535
commit 87cccefddb
32 changed files with 6280 additions and 668 deletions

View File

@ -6,7 +6,6 @@ import (
"git.solsynth.dev/hydrogen/passport/pkg/database"
"git.solsynth.dev/hydrogen/passport/pkg/models"
"git.solsynth.dev/hydrogen/passport/pkg/security"
"github.com/google/uuid"
"github.com/samber/lo"
"gorm.io/gorm"
@ -23,14 +22,14 @@ func GetAccount(id uint) (models.Account, error) {
return account, nil
}
func LookupAccount(id string) (models.Account, error) {
func LookupAccount(probe string) (models.Account, error) {
var account models.Account
if err := database.C.Where(models.Account{Name: id}).First(&account).Error; err == nil {
if err := database.C.Where(models.Account{Name: probe}).First(&account).Error; err == nil {
return account, nil
}
var contact models.AccountContact
if err := database.C.Where(models.AccountContact{Content: id}).First(&contact).Error; err == nil {
if err := database.C.Where(models.AccountContact{Content: probe}).First(&contact).Error; err == nil {
if err := database.C.
Where(models.Account{
BaseModel: models.BaseModel{ID: contact.AccountID},
@ -52,7 +51,7 @@ func CreateAccount(name, nick, email, password string) (models.Account, error) {
Factors: []models.AuthFactor{
{
Type: models.PasswordAuthFactor,
Secret: security.HashPassword(password),
Secret: HashPassword(password),
},
{
Type: models.EmailPasswordFactor,

View File

@ -6,7 +6,6 @@ import (
"git.solsynth.dev/hydrogen/passport/pkg/database"
"git.solsynth.dev/hydrogen/passport/pkg/models"
"git.solsynth.dev/hydrogen/passport/pkg/security"
"github.com/gofiber/fiber/v2"
jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log"
@ -16,12 +15,12 @@ import (
const authContextBucket = "AuthContext"
func Authenticate(access, refresh string, depth int) (user models.Account, newAccess, newRefresh string, err error) {
var claims security.PayloadClaims
claims, err = security.DecodeJwt(access)
var claims PayloadClaims
claims, err = DecodeJwt(access)
if err != nil {
if len(refresh) > 0 && depth < 1 {
// Auto refresh and retry
newAccess, newRefresh, err = security.RefreshToken(refresh)
newAccess, newRefresh, err = RefreshToken(refresh)
if err == nil {
return Authenticate(newAccess, newRefresh, depth+1)
}
@ -74,7 +73,7 @@ func GetAuthContext(jti string) (models.AuthContext, error) {
})
if err == nil && time.Now().Unix() >= ctx.ExpiredAt.Unix() {
RevokeAuthContext(jti)
_ = RevokeAuthContext(jti)
return ctx, fmt.Errorf("auth context has been expired")
}
@ -86,7 +85,7 @@ func GrantAuthContext(jti string) (models.AuthContext, error) {
var ctx models.AuthContext
// Query data from primary database
session, err := LookupSessionWithToken(jti)
session, err := GetTicketWithToken(jti)
if err != nil {
return ctx, fmt.Errorf("invalid auth session: %v", err)
} else if err := session.IsAvailable(); err != nil {
@ -101,7 +100,7 @@ func GrantAuthContext(jti string) (models.AuthContext, error) {
// Every context should expires in some while
// Once user update their account info, this will have delay to update
ctx = models.AuthContext{
Session: session,
Ticket: session,
Account: user,
ExpiredAt: time.Now().Add(5 * time.Minute),
}

View File

@ -1,26 +0,0 @@
package services
import (
"git.solsynth.dev/hydrogen/passport/pkg/database"
"git.solsynth.dev/hydrogen/passport/pkg/models"
)
func LookupChallenge(id uint) (models.AuthChallenge, error) {
var challenge models.AuthChallenge
err := database.C.Where(models.AuthChallenge{
BaseModel: models.BaseModel{ID: id},
}).First(&challenge).Error
return challenge, err
}
func LookupChallengeWithFingerprint(id uint, ip string, ua string) (models.AuthChallenge, error) {
var challenge models.AuthChallenge
err := database.C.Where(models.AuthChallenge{
BaseModel: models.BaseModel{ID: id},
IpAddress: ip,
UserAgent: ua,
}).First(&challenge).Error
return challenge, err
}

12
pkg/services/encryptor.go Normal file
View File

@ -0,0 +1,12 @@
package services
import "golang.org/x/crypto/bcrypt"
func HashPassword(raw string) string {
data, _ := bcrypt.GenerateFromPassword([]byte(raw), 12)
return string(data)
}
func VerifyPassword(text string, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(password), []byte(text)) == nil
}

View File

@ -2,6 +2,7 @@ package services
import (
"fmt"
"github.com/samber/lo"
"git.solsynth.dev/hydrogen/passport/pkg/database"
"git.solsynth.dev/hydrogen/passport/pkg/models"
@ -24,7 +25,17 @@ Thank you for your cooperation in helping us maintain the security of your accou
Best regards,
%s`
func LookupFactor(id uint) (models.AuthFactor, error) {
func GetPasswordFactor(userId uint) (models.AuthFactor, error) {
var factor models.AuthFactor
err := database.C.Where(models.AuthFactor{
Type: models.PasswordAuthFactor,
AccountID: userId,
}).First(&factor).Error
return factor, err
}
func GetFactor(id uint) (models.AuthFactor, error) {
var factor models.AuthFactor
err := database.C.Where(models.AuthFactor{
BaseModel: models.BaseModel{ID: id},
@ -33,10 +44,10 @@ func LookupFactor(id uint) (models.AuthFactor, error) {
return factor, err
}
func LookupFactorsByUser(uid uint) ([]models.AuthFactor, error) {
func ListUserFactor(userId uint) ([]models.AuthFactor, error) {
var factors []models.AuthFactor
err := database.C.Where(models.AuthFactor{
AccountID: uid,
AccountID: userId,
}).Find(&factors).Error
return factors, err
@ -68,3 +79,22 @@ func GetFactorCode(factor models.AuthFactor) (bool, error) {
return false, nil
}
}
func CheckFactor(factor models.AuthFactor, code string) error {
switch factor.Type {
case models.PasswordAuthFactor:
return lo.Ternary(
VerifyPassword(code, factor.Secret),
nil,
fmt.Errorf("invalid password"),
)
case models.EmailPasswordFactor:
return lo.Ternary(
code == factor.Secret,
nil,
fmt.Errorf("invalid verification code"),
)
}
return nil
}

81
pkg/services/jwt.go Normal file
View File

@ -0,0 +1,81 @@
package services
import (
"fmt"
"github.com/gofiber/fiber/v2"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/spf13/viper"
)
var CookieAccessKey = "passport_auth_key"
var CookieRefreshKey = "passport_refresh_key"
type PayloadClaims struct {
jwt.RegisteredClaims
SessionID string `json:"sed"`
Type string `json:"typ"`
}
const (
JwtAccessType = "access"
JwtRefreshType = "refresh"
)
func EncodeJwt(id string, typ, sub, sed string, aud []string, exp time.Time) (string, error) {
tk := jwt.NewWithClaims(jwt.SigningMethodHS512, PayloadClaims{
jwt.RegisteredClaims{
Subject: sub,
Audience: aud,
Issuer: fmt.Sprintf("https://%s", viper.GetString("domain")),
ExpiresAt: jwt.NewNumericDate(exp),
NotBefore: jwt.NewNumericDate(time.Now()),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: id,
},
sed,
typ,
})
return tk.SignedString([]byte(viper.GetString("secret")))
}
func DecodeJwt(str string) (PayloadClaims, error) {
var claims PayloadClaims
tk, err := jwt.ParseWithClaims(str, &claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(viper.GetString("secret")), nil
})
if err != nil {
return claims, err
}
if data, ok := tk.Claims.(*PayloadClaims); ok {
return *data, nil
} else {
return claims, fmt.Errorf("unexpected token payload: not payload claims type")
}
}
func SetJwtCookieSet(c *fiber.Ctx, access, refresh string) {
c.Cookie(&fiber.Cookie{
Name: CookieAccessKey,
Value: access,
Domain: viper.GetString("security.cookie_domain"),
SameSite: viper.GetString("security.cookie_samesite"),
Expires: time.Now().Add(60 * time.Minute),
Path: "/",
})
c.Cookie(&fiber.Cookie{
Name: CookieRefreshKey,
Value: refresh,
Domain: viper.GetString("security.cookie_domain"),
SameSite: viper.GetString("security.cookie_samesite"),
Expires: time.Now().Add(24 * 30 * time.Hour),
Path: "/",
})
}

View File

@ -1,28 +1,15 @@
package services
import (
"time"
"git.solsynth.dev/hydrogen/passport/pkg/database"
"git.solsynth.dev/hydrogen/passport/pkg/models"
jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"go.etcd.io/bbolt"
"time"
)
func LookupSessionWithToken(tokenId string) (models.AuthSession, error) {
var session models.AuthSession
if err := database.C.
Where(models.AuthSession{AccessToken: tokenId}).
Or(models.AuthSession{RefreshToken: tokenId}).
First(&session).Error; err != nil {
return session, err
}
return session, nil
}
func DoAutoSignoff() {
duration := time.Duration(viper.GetInt64("security.auto_signoff_duration")) * time.Second
divider := time.Now().Add(-duration)
@ -31,7 +18,7 @@ func DoAutoSignoff() {
if tx := database.C.
Where("last_grant_at < ?", divider).
Delete(&models.AuthSession{}); tx.Error != nil {
Delete(&models.AuthTicket{}); tx.Error != nil {
log.Error().Err(tx.Error).Msg("An error occurred when running auto sign off...")
} else {
log.Debug().Int64("affected", tx.RowsAffected).Msg("Auto sign off accomplished.")

136
pkg/services/ticket.go Normal file
View File

@ -0,0 +1,136 @@
package services
import (
"fmt"
"time"
"github.com/google/uuid"
"git.solsynth.dev/hydrogen/passport/pkg/database"
"git.solsynth.dev/hydrogen/passport/pkg/models"
"github.com/samber/lo"
)
func DetectRisk(user models.Account, ip, ua string) bool {
var secureFactor int64
if err := database.C.Where(models.AuthTicket{
AccountID: user.ID,
IpAddress: ip,
}).Model(models.AuthTicket{}).Count(&secureFactor).Error; err == nil {
if secureFactor >= 1 {
return false
}
}
return true
}
func NewTicket(user models.Account, ip, ua string) (models.AuthTicket, error) {
var ticket models.AuthTicket
if err := database.C.Where(models.AuthTicket{
AccountID: user.ID,
}).First(&ticket).Error; err == nil {
return ticket, nil
}
ticket = models.AuthTicket{
Claims: []string{"*"},
Audiences: []string{"passport"},
IpAddress: ip,
UserAgent: ua,
RequireMFA: DetectRisk(user, ip, ua),
ExpiredAt: lo.ToPtr(time.Now().Add(2 * time.Hour)),
AvailableAt: nil,
AccountID: user.ID,
}
err := database.C.Save(&ticket).Error
return ticket, err
}
func NewOauthTicket(
user models.Account,
client models.ThirdClient,
claims, audiences []string,
ip, ua string,
) (models.AuthTicket, error) {
ticket := models.AuthTicket{
Claims: claims,
Audiences: audiences,
IpAddress: ip,
UserAgent: ua,
RequireMFA: DetectRisk(user, ip, ua),
GrantToken: lo.ToPtr(uuid.NewString()),
AccessToken: lo.ToPtr(uuid.NewString()),
RefreshToken: lo.ToPtr(uuid.NewString()),
AvailableAt: lo.ToPtr(time.Now()),
ExpiredAt: lo.ToPtr(time.Now()),
ClientID: &client.ID,
AccountID: user.ID,
}
if err := database.C.Save(&ticket).Error; err != nil {
return ticket, err
}
return ticket, nil
}
func ActiveTicketWithPassword(ticket models.AuthTicket, password string) (models.AuthTicket, error) {
if ticket.AvailableAt != nil {
return ticket, nil
} else if !ticket.RequireAuthenticate {
return ticket, fmt.Errorf("detected risk, multi factor authentication required")
}
if factor, err := GetPasswordFactor(ticket.AccountID); err != nil {
return ticket, fmt.Errorf("unable to active ticket: %v", err)
} else if err = CheckFactor(factor, password); err != nil {
return ticket, err
}
ticket.AvailableAt = lo.ToPtr(time.Now())
if !ticket.RequireAuthenticate && !ticket.RequireMFA {
ticket.AvailableAt = lo.ToPtr(time.Now())
}
if err := database.C.Save(&ticket).Error; err != nil {
return ticket, err
}
return ticket, nil
}
func ActiveTicketWithMFA(ticket models.AuthTicket, factor models.AuthFactor, code string) (models.AuthTicket, error) {
if ticket.AvailableAt != nil {
return ticket, nil
} else if !ticket.RequireMFA {
return ticket, nil
}
if err := CheckFactor(factor, code); err != nil {
return ticket, fmt.Errorf("invalid code: %v", err)
}
ticket.RequireMFA = false
if !ticket.RequireAuthenticate && !ticket.RequireMFA {
ticket.AvailableAt = lo.ToPtr(time.Now())
}
if err := database.C.Save(&ticket).Error; err != nil {
return ticket, err
}
return ticket, nil
}
func RegenSession(session models.AuthTicket) (models.AuthTicket, error) {
session.GrantToken = lo.ToPtr(uuid.NewString())
session.AccessToken = lo.ToPtr(uuid.NewString())
session.RefreshToken = lo.ToPtr(uuid.NewString())
err := database.C.Save(&session).Error
return session, err
}

View File

@ -0,0 +1,29 @@
package services
import (
"git.solsynth.dev/hydrogen/passport/pkg/database"
"git.solsynth.dev/hydrogen/passport/pkg/models"
)
func GetTicket(id uint) (models.AuthTicket, error) {
var ticket models.AuthTicket
if err := database.C.
Where(&models.AuthTicket{BaseModel: models.BaseModel{ID: id}}).
First(&ticket).Error; err != nil {
return ticket, err
}
return ticket, nil
}
func GetTicketWithToken(tokenId string) (models.AuthTicket, error) {
var ticket models.AuthTicket
if err := database.C.
Where(models.AuthTicket{AccessToken: &tokenId}).
Or(models.AuthTicket{RefreshToken: &tokenId}).
First(&ticket).Error; err != nil {
return ticket, err
}
return ticket, nil
}

View File

@ -0,0 +1,98 @@
package services
import (
"fmt"
"git.solsynth.dev/hydrogen/passport/pkg/database"
"git.solsynth.dev/hydrogen/passport/pkg/models"
"github.com/samber/lo"
"github.com/spf13/viper"
"strconv"
"time"
)
func GetToken(ticket models.AuthTicket) (string, string, error) {
var refresh, access string
if err := ticket.IsAvailable(); err != nil {
return refresh, access, err
}
if ticket.AccessToken == nil || ticket.RefreshToken == nil {
return refresh, access, fmt.Errorf("unable to encode token, access or refresh token id missing")
}
accessDuration := time.Duration(viper.GetInt64("access_token_duration")) * time.Second
refreshDuration := time.Duration(viper.GetInt64("refresh_token_duration")) * time.Second
var err error
sub := strconv.Itoa(int(ticket.AccountID))
sed := strconv.Itoa(int(ticket.ID))
access, err = EncodeJwt(*ticket.AccessToken, JwtAccessType, sub, sed, ticket.Audiences, time.Now().Add(accessDuration))
if err != nil {
return refresh, access, err
}
refresh, err = EncodeJwt(*ticket.RefreshToken, JwtRefreshType, sub, sed, ticket.Audiences, time.Now().Add(refreshDuration))
if err != nil {
return refresh, access, err
}
ticket.LastGrantAt = lo.ToPtr(time.Now())
database.C.Save(&ticket)
return access, refresh, nil
}
func ExchangeToken(token string) (string, string, error) {
var ticket models.AuthTicket
if err := database.C.Where(models.AuthTicket{GrantToken: &token}).First(&ticket).Error; err != nil {
return "", "", err
} else if ticket.LastGrantAt != nil {
return "", "", fmt.Errorf("ticket was granted the first token, use refresh token instead")
} else if len(ticket.Audiences) > 1 {
return "", "", fmt.Errorf("should use authorization code grant type")
}
return GetToken(ticket)
}
func ExchangeOauthToken(clientId, clientSecret, redirectUri, token string) (string, string, error) {
var client models.ThirdClient
if err := database.C.Where(models.ThirdClient{Alias: clientId}).First(&client).Error; err != nil {
return "", "", err
} else if client.Secret != clientSecret {
return "", "", fmt.Errorf("invalid client secret")
} else if !client.IsDraft && !lo.Contains(client.Callbacks, redirectUri) {
return "", "", fmt.Errorf("invalid redirect uri")
}
var ticket models.AuthTicket
if err := database.C.Where(models.AuthTicket{GrantToken: &token}).First(&ticket).Error; err != nil {
return "", "", err
} else if ticket.LastGrantAt != nil {
return "", "", fmt.Errorf("ticket was granted the first token, use refresh token instead")
}
return GetToken(ticket)
}
func RefreshToken(token string) (string, string, error) {
parseInt := func(str string) int {
val, _ := strconv.Atoi(str)
return val
}
var ticket models.AuthTicket
if claims, err := DecodeJwt(token); err != nil {
return "404", "403", err
} else if claims.Type != JwtRefreshType {
return "404", "403", fmt.Errorf("invalid token type, expected refresh token")
} else if err := database.C.Where(models.AuthTicket{
BaseModel: models.BaseModel{ID: uint(parseInt(claims.SessionID))},
}).First(&ticket).Error; err != nil {
return "404", "403", err
}
if ticket, err := RegenSession(ticket); err != nil {
return "404", "403", err
} else {
return GetToken(ticket)
}
}