🎉 Initial Commit
This commit is contained in:
82
pkg/security/challanges.go
Normal file
82
pkg/security/challanges.go
Normal file
@ -0,0 +1,82 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/database"
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/models"
|
||||
"github.com/samber/lo"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
func NewChallenge(account models.Account, factors []models.AuthFactor, ip, ua string) (models.AuthChallenge, error) {
|
||||
risk := 3
|
||||
var challenge models.AuthChallenge
|
||||
// Pickup any challenge if possible
|
||||
if err := database.C.Where(models.AuthChallenge{
|
||||
AccountID: account.ID,
|
||||
State: models.ActiveChallengeState,
|
||||
}).First(&challenge).Error; err == nil {
|
||||
return challenge, nil
|
||||
}
|
||||
|
||||
// Reduce the risk level
|
||||
var secureFactor int64
|
||||
if err := database.C.Where(models.AuthChallenge{
|
||||
AccountID: account.ID,
|
||||
IpAddress: ip,
|
||||
}).Model(models.AuthChallenge{}).Count(&secureFactor).Error; err != nil {
|
||||
return challenge, err
|
||||
}
|
||||
if secureFactor >= 3 {
|
||||
risk -= 2
|
||||
} else if secureFactor >= 1 {
|
||||
risk -= 1
|
||||
}
|
||||
|
||||
// Thinking of the requirements factors
|
||||
requirements := int(math.Max(float64(len(factors)), math.Min(float64(risk), 1)))
|
||||
|
||||
challenge = models.AuthChallenge{
|
||||
IpAddress: ip,
|
||||
UserAgent: ua,
|
||||
RiskLevel: risk,
|
||||
Requirements: requirements,
|
||||
BlacklistFactors: datatypes.NewJSONType([]uint{}),
|
||||
State: models.ActiveChallengeState,
|
||||
ExpiredAt: time.Now().Add(2 * time.Hour),
|
||||
AccountID: account.ID,
|
||||
}
|
||||
|
||||
err := database.C.Save(&challenge).Error
|
||||
|
||||
return challenge, err
|
||||
}
|
||||
|
||||
func DoChallenge(challenge models.AuthChallenge, factor models.AuthFactor, code string) error {
|
||||
if err := challenge.IsAvailable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if challenge.Progress >= challenge.Requirements {
|
||||
return fmt.Errorf("challenge already passed")
|
||||
}
|
||||
|
||||
blacklist := challenge.BlacklistFactors.Data()
|
||||
if lo.Contains(blacklist, factor.ID) {
|
||||
return fmt.Errorf("factor in blacklist, please change another factor to challenge")
|
||||
}
|
||||
if err := VerifyFactor(factor, code); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
challenge.Progress++
|
||||
challenge.BlacklistFactors = datatypes.NewJSONType(append(blacklist, factor.ID))
|
||||
|
||||
if err := database.C.Save(&challenge).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
12
pkg/security/encryptor.go
Normal file
12
pkg/security/encryptor.go
Normal file
@ -0,0 +1,12 @@
|
||||
package security
|
||||
|
||||
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
|
||||
}
|
31
pkg/security/factors.go
Normal file
31
pkg/security/factors.go
Normal file
@ -0,0 +1,31 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/models"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func GetFactorCode(factor models.AuthFactor) error {
|
||||
switch factor.Type {
|
||||
case models.EmailPasswordFactor:
|
||||
// TODO
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported factor to get code")
|
||||
}
|
||||
}
|
||||
|
||||
func VerifyFactor(factor models.AuthFactor, code string) error {
|
||||
switch factor.Type {
|
||||
case models.PasswordAuthFactor:
|
||||
return lo.Ternary(
|
||||
VerifyPassword(code, factor.Secret),
|
||||
nil,
|
||||
fmt.Errorf("invalid password"),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
58
pkg/security/jwt.go
Normal file
58
pkg/security/jwt.go
Normal file
@ -0,0 +1,58 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type PayloadClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
|
||||
Type string `json:"typ"`
|
||||
Value any `json:"val"`
|
||||
}
|
||||
|
||||
const (
|
||||
JwtAccessType = "access"
|
||||
JwtRefreshType = "refresh"
|
||||
)
|
||||
|
||||
func EncodeJwt(id string, val any, typ, sub string, aud []string, exp time.Time) (string, error) {
|
||||
tk := jwt.NewWithClaims(jwt.SigningMethodHS512, PayloadClaims{
|
||||
jwt.RegisteredClaims{
|
||||
Subject: sub,
|
||||
Audience: aud,
|
||||
Issuer: viper.GetString("domain"),
|
||||
ExpiresAt: jwt.NewNumericDate(exp),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ID: id,
|
||||
},
|
||||
typ,
|
||||
val,
|
||||
})
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
98
pkg/security/sessions.go
Normal file
98
pkg/security/sessions.go
Normal file
@ -0,0 +1,98 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/database"
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func GrantSession(challenge models.AuthChallenge, claims []string, expired *time.Time, available *time.Time) (models.AuthSession, error) {
|
||||
var session models.AuthSession
|
||||
if err := challenge.IsAvailable(); err != nil {
|
||||
return session, err
|
||||
}
|
||||
if challenge.Progress < challenge.Requirements {
|
||||
return session, fmt.Errorf("challenge haven't passed")
|
||||
}
|
||||
|
||||
challenge.State = models.FinishChallengeState
|
||||
|
||||
session = models.AuthSession{
|
||||
Claims: claims,
|
||||
Challenge: challenge,
|
||||
GrantToken: uuid.NewString(),
|
||||
AccessToken: uuid.NewString(),
|
||||
RefreshToken: uuid.NewString(),
|
||||
ExpiredAt: expired,
|
||||
AvailableAt: available,
|
||||
AccountID: challenge.AccountID,
|
||||
}
|
||||
|
||||
if err := database.C.Save(&challenge).Error; err != nil {
|
||||
return session, err
|
||||
} else if err := database.C.Save(&session).Error; err != nil {
|
||||
return session, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func GetToken(session models.AuthSession, aud ...string) (string, string, error) {
|
||||
var refresh, access string
|
||||
if err := session.IsAvailable(); err != nil {
|
||||
return refresh, access, err
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
sub := strconv.Itoa(int(session.ID))
|
||||
access, err = EncodeJwt(session.AccessToken, nil, JwtAccessType, sub, aud, time.Now().Add(30*time.Minute))
|
||||
if err != nil {
|
||||
return refresh, access, err
|
||||
}
|
||||
refresh, err = EncodeJwt(session.RefreshToken, nil, JwtRefreshType, sub, aud, time.Now().Add(30*24*time.Hour))
|
||||
if err != nil {
|
||||
return refresh, access, err
|
||||
}
|
||||
|
||||
session.LastGrantAt = lo.ToPtr(time.Now())
|
||||
database.C.Save(&session)
|
||||
|
||||
return access, refresh, nil
|
||||
}
|
||||
|
||||
func ExchangeToken(token string, aud ...string) (string, string, error) {
|
||||
var session models.AuthSession
|
||||
if err := database.C.Where(models.AuthSession{GrantToken: token}).First(&session).Error; err != nil {
|
||||
return "404", "403", err
|
||||
} else if session.LastGrantAt != nil {
|
||||
return "404", "403", fmt.Errorf("session was granted the first token, use refresh token instead")
|
||||
}
|
||||
|
||||
return GetToken(session, aud...)
|
||||
}
|
||||
|
||||
func RefreshToken(token string, aud ...string) (string, string, error) {
|
||||
parseInt := func(str string) int {
|
||||
val, _ := strconv.Atoi(str)
|
||||
return val
|
||||
}
|
||||
|
||||
var session models.AuthSession
|
||||
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.AuthSession{
|
||||
BaseModel: models.BaseModel{ID: uint(parseInt(claims.Subject))},
|
||||
}).First(&session).Error; err != nil {
|
||||
return "404", "403", err
|
||||
}
|
||||
|
||||
return GetToken(session, aud...)
|
||||
}
|
Reference in New Issue
Block a user