🎉 Initial Commit
This commit is contained in:
53
pkg/cmd/main.go
Normal file
53
pkg/cmd/main.go
Normal file
@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
passport "code.smartsheep.studio/hydrogen/passport/pkg"
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/database"
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/server"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func init() {
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout})
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Configure settings
|
||||
viper.AddConfigPath(".")
|
||||
viper.AddConfigPath("..")
|
||||
viper.SetConfigName("settings")
|
||||
viper.SetConfigType("yaml")
|
||||
|
||||
// Load settings
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
log.Panic().Err(err).Msg("An error occurred when loading settings.")
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
if err := database.NewSource(); err != nil {
|
||||
log.Fatal().Err(err).Msg("An error occurred when connect to database.")
|
||||
} else if err := database.RunMigration(database.C); err != nil {
|
||||
log.Fatal().Err(err).Msg("An error occurred when running database auto migration.")
|
||||
}
|
||||
|
||||
// Create connection between bus
|
||||
server.InitConnection(viper.GetString("host"), viper.GetString("id"))
|
||||
server.PublishCommands(server.C)
|
||||
go server.C.ListenToServer()
|
||||
|
||||
// Messages
|
||||
log.Info().Msgf("Passport v%s is started...", passport.AppVersion)
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Info().Msgf("Passport v%s is quitting...", passport.AppVersion)
|
||||
}
|
20
pkg/database/migrator.go
Normal file
20
pkg/database/migrator.go
Normal file
@ -0,0 +1,20 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RunMigration(source *gorm.DB) error {
|
||||
if err := source.AutoMigrate(
|
||||
&models.Account{},
|
||||
&models.AuthFactor{},
|
||||
&models.AccountContact{},
|
||||
&models.AuthSession{},
|
||||
&models.AuthChallenge{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
28
pkg/database/source.go
Normal file
28
pkg/database/source.go
Normal file
@ -0,0 +1,28 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/samber/lo"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
var C *gorm.DB
|
||||
|
||||
func NewSource() error {
|
||||
var err error
|
||||
|
||||
dialector := postgres.Open(viper.GetString("database.dsn"))
|
||||
C, err = gorm.Open(dialector, &gorm.Config{NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: viper.GetString("database.prefix"),
|
||||
}, Logger: logger.New(&log.Logger, logger.Config{
|
||||
Colorful: true,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
LogLevel: lo.Ternary(viper.GetBool("debug"), logger.Info, logger.Silent),
|
||||
})})
|
||||
|
||||
return err
|
||||
}
|
5
pkg/meta.go
Normal file
5
pkg/meta.go
Normal file
@ -0,0 +1,5 @@
|
||||
package passport
|
||||
|
||||
const (
|
||||
AppVersion = "1.0.0"
|
||||
)
|
39
pkg/models/accounts.go
Normal file
39
pkg/models/accounts.go
Normal file
@ -0,0 +1,39 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type AccountState = int8
|
||||
|
||||
const (
|
||||
PendingAccountState = AccountState(iota)
|
||||
ActiveAccountState
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
BaseModel
|
||||
|
||||
Name string `json:"name" gorm:"uniqueIndex"`
|
||||
Nick string `json:"nick"`
|
||||
State AccountState `json:"state"`
|
||||
Session []AuthSession `json:"sessions"`
|
||||
Challenges []AuthChallenge `json:"challenges"`
|
||||
Factors []AuthFactor `json:"factors"`
|
||||
Contacts []AccountContact `json:"contacts"`
|
||||
}
|
||||
|
||||
type AccountContactType = int8
|
||||
|
||||
const (
|
||||
EmailAccountContact = AccountContactType(iota)
|
||||
)
|
||||
|
||||
type AccountContact struct {
|
||||
BaseModel
|
||||
|
||||
Type int8 `json:"type"`
|
||||
Content string `json:"content" gorm:"uniqueIndex"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
VerifiedAt *time.Time `json:"verified_at"`
|
||||
AccountID uint `json:"account_id"`
|
||||
}
|
79
pkg/models/auth.go
Normal file
79
pkg/models/auth.go
Normal file
@ -0,0 +1,79 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type AuthFactorType = int8
|
||||
|
||||
const (
|
||||
PasswordAuthFactor = AuthFactorType(iota)
|
||||
EmailPasswordFactor
|
||||
)
|
||||
|
||||
type AuthFactor struct {
|
||||
BaseModel
|
||||
|
||||
Type int8 `json:"type"`
|
||||
Secret string `json:"secret"`
|
||||
Config JSONMap `json:"config"`
|
||||
AccountID uint `json:"account_id"`
|
||||
}
|
||||
|
||||
type AuthSession struct {
|
||||
BaseModel
|
||||
|
||||
Claims datatypes.JSONSlice[string] `json:"claims"`
|
||||
Challenge AuthChallenge `json:"challenge" gorm:"foreignKey:SessionID"`
|
||||
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"`
|
||||
AccountID uint `json:"account_id"`
|
||||
}
|
||||
|
||||
func (v AuthSession) IsAvailable() error {
|
||||
if v.AvailableAt != nil && time.Now().Unix() < v.AvailableAt.Unix() {
|
||||
return fmt.Errorf("session isn't available yet")
|
||||
}
|
||||
if v.ExpiredAt != nil && time.Now().Unix() > v.ExpiredAt.Unix() {
|
||||
return fmt.Errorf("session expired")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type AuthChallengeState = int8
|
||||
|
||||
const (
|
||||
ActiveChallengeState = AuthChallengeState(iota)
|
||||
FinishChallengeState
|
||||
)
|
||||
|
||||
type AuthChallenge struct {
|
||||
BaseModel
|
||||
|
||||
IpAddress string `json:"ip_address"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
RiskLevel int `json:"risk_level"`
|
||||
Progress int `json:"progress"`
|
||||
Requirements int `json:"requirements"`
|
||||
BlacklistFactors datatypes.JSONType[[]uint] `json:"blacklist_factors"`
|
||||
State int8 `json:"state"`
|
||||
ExpiredAt time.Time `json:"expired_at"`
|
||||
SessionID *uint `json:"session_id"`
|
||||
AccountID uint `json:"account_id"`
|
||||
}
|
||||
|
||||
func (v AuthChallenge) IsAvailable() error {
|
||||
if time.Now().Unix() > v.ExpiredAt.Unix() {
|
||||
return fmt.Errorf("challenge expired")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
17
pkg/models/base.go
Normal file
17
pkg/models/base.go
Normal file
@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type JSONMap = datatypes.JSONType[map[string]any]
|
||||
|
||||
type BaseModel struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
}
|
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...)
|
||||
}
|
44
pkg/server/accounts.go
Normal file
44
pkg/server/accounts.go
Normal file
@ -0,0 +1,44 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"code.smartsheep.studio/hydrogen/bus/pkg/kit/adaptor"
|
||||
"code.smartsheep.studio/hydrogen/bus/pkg/kit/publisher"
|
||||
"code.smartsheep.studio/hydrogen/bus/pkg/wire"
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/database"
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/models"
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/security"
|
||||
)
|
||||
|
||||
func doRegister(c *publisher.RequestCtx) error {
|
||||
data := adaptor.ParseAnyToStruct[struct {
|
||||
Name string `json:"name"`
|
||||
Nick string `json:"nick"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}](c.Parameters)
|
||||
|
||||
user := models.Account{
|
||||
Name: data.Name,
|
||||
Nick: data.Nick,
|
||||
State: models.PendingAccountState,
|
||||
Factors: []models.AuthFactor{
|
||||
{
|
||||
Type: models.PasswordAuthFactor,
|
||||
Secret: security.HashPassword(data.Password),
|
||||
},
|
||||
},
|
||||
Contacts: []models.AccountContact{
|
||||
{
|
||||
Type: models.EmailAccountContact,
|
||||
Content: data.Email,
|
||||
VerifiedAt: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := database.C.Create(&user).Error; err != nil {
|
||||
return c.SendError(wire.InvalidActions, err)
|
||||
}
|
||||
|
||||
return c.SendResponse(user)
|
||||
}
|
116
pkg/server/challanges.go
Normal file
116
pkg/server/challanges.go
Normal file
@ -0,0 +1,116 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"code.smartsheep.studio/hydrogen/bus/pkg/kit/adaptor"
|
||||
"code.smartsheep.studio/hydrogen/bus/pkg/kit/publisher"
|
||||
"code.smartsheep.studio/hydrogen/bus/pkg/wire"
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/security"
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/services"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func startChallenge(c *publisher.RequestCtx) error {
|
||||
meta := adaptor.ParseAnyToStruct[wire.ClientMetadata](c.Metadata)
|
||||
data := adaptor.ParseAnyToStruct[struct {
|
||||
ID string `json:"id"`
|
||||
}](c.Parameters)
|
||||
|
||||
user, err := services.LookupAccount(data.ID)
|
||||
if err != nil {
|
||||
return c.SendError(wire.InvalidActions, err)
|
||||
}
|
||||
factors, err := services.LookupFactorsByUser(user.ID)
|
||||
if err != nil {
|
||||
return c.SendError(wire.InvalidActions, err)
|
||||
}
|
||||
|
||||
challenge, err := security.NewChallenge(user, factors, meta.ClientIp, meta.UserAgent)
|
||||
if err != nil {
|
||||
return c.SendError(wire.InvalidActions, err)
|
||||
}
|
||||
|
||||
return c.SendResponse(map[string]any{
|
||||
"display_name": user.Nick,
|
||||
"challenge": challenge,
|
||||
"factors": factors,
|
||||
})
|
||||
}
|
||||
|
||||
func doChallenge(c *publisher.RequestCtx) error {
|
||||
meta := adaptor.ParseAnyToStruct[wire.ClientMetadata](c.Metadata)
|
||||
data := adaptor.ParseAnyToStruct[struct {
|
||||
ChallengeID uint `json:"challenge_id"`
|
||||
FactorID uint `json:"factor_id"`
|
||||
Secret string `json:"secret"`
|
||||
}](c.Parameters)
|
||||
|
||||
challenge, err := services.LookupChallengeWithFingerprint(data.ChallengeID, meta.ClientIp, meta.UserAgent)
|
||||
if err != nil {
|
||||
return c.SendError(wire.InvalidActions, err)
|
||||
}
|
||||
|
||||
factor, err := services.LookupFactor(data.FactorID)
|
||||
if err != nil {
|
||||
return c.SendError(wire.InvalidActions, err)
|
||||
}
|
||||
|
||||
if err := security.DoChallenge(challenge, factor, data.Secret); err != nil {
|
||||
return c.SendError(wire.InvalidActions, err)
|
||||
}
|
||||
|
||||
challenge, err = services.LookupChallenge(data.ChallengeID)
|
||||
if err != nil {
|
||||
return c.SendError(wire.InvalidActions, err)
|
||||
} else if challenge.Progress >= challenge.Requirements {
|
||||
session, err := security.GrantSession(challenge, []string{"*"}, nil, lo.ToPtr(time.Now()))
|
||||
if err != nil {
|
||||
return c.SendError(wire.InvalidActions, err)
|
||||
}
|
||||
|
||||
return c.SendResponse(map[string]any{
|
||||
"is_finished": true,
|
||||
"challenge": challenge,
|
||||
"session": session,
|
||||
})
|
||||
}
|
||||
|
||||
return c.SendResponse(map[string]any{
|
||||
"is_finished": false,
|
||||
"challenge": challenge,
|
||||
"session": nil,
|
||||
})
|
||||
}
|
||||
|
||||
func exchangeToken(c *publisher.RequestCtx) error {
|
||||
data := adaptor.ParseAnyToStruct[struct {
|
||||
GrantToken string `json:"token"`
|
||||
}](c.Parameters)
|
||||
|
||||
access, refresh, err := security.ExchangeToken(data.GrantToken)
|
||||
if err != nil {
|
||||
return c.SendError(wire.InvalidActions, err)
|
||||
}
|
||||
|
||||
return c.SendResponse(map[string]any{
|
||||
"access_token": access,
|
||||
"refresh_token": refresh,
|
||||
})
|
||||
}
|
||||
|
||||
func refreshToken(c *publisher.RequestCtx) error {
|
||||
data := adaptor.ParseAnyToStruct[struct {
|
||||
RefreshToken string `json:"token"`
|
||||
}](c.Parameters)
|
||||
|
||||
access, refresh, err := security.RefreshToken(data.RefreshToken)
|
||||
if err != nil {
|
||||
return c.SendError(wire.InvalidActions, err)
|
||||
}
|
||||
|
||||
return c.SendResponse(map[string]any{
|
||||
"access_token": access,
|
||||
"refresh_token": refresh,
|
||||
})
|
||||
}
|
26
pkg/server/factors.go
Normal file
26
pkg/server/factors.go
Normal file
@ -0,0 +1,26 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"code.smartsheep.studio/hydrogen/bus/pkg/kit/adaptor"
|
||||
"code.smartsheep.studio/hydrogen/bus/pkg/kit/publisher"
|
||||
"code.smartsheep.studio/hydrogen/bus/pkg/wire"
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/security"
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/services"
|
||||
)
|
||||
|
||||
func getFactorToken(c *publisher.RequestCtx) error {
|
||||
data := adaptor.ParseAnyToStruct[struct {
|
||||
ID uint `json:"id"`
|
||||
}](c.Parameters)
|
||||
|
||||
factor, err := services.LookupFactor(data.ID)
|
||||
if err != nil {
|
||||
return c.SendError(wire.InvalidActions, err)
|
||||
}
|
||||
|
||||
if err := security.GetFactorCode(factor); err != nil {
|
||||
return c.SendError(wire.InvalidActions, err)
|
||||
}
|
||||
|
||||
return c.SendResponse(nil)
|
||||
}
|
45
pkg/server/index.go
Normal file
45
pkg/server/index.go
Normal file
@ -0,0 +1,45 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"code.smartsheep.studio/hydrogen/bus/pkg/kit/publisher"
|
||||
"code.smartsheep.studio/hydrogen/bus/pkg/wire"
|
||||
)
|
||||
|
||||
var Commands = map[string]publisher.CommandManifest{
|
||||
"passport.accounts.new": {
|
||||
Name: "Create a new account",
|
||||
Description: "Create a new account on passport platform.",
|
||||
Requirements: wire.CommandRequirements{},
|
||||
Handle: doRegister,
|
||||
},
|
||||
"passport.auth.challenges.new": {
|
||||
Name: "Create a new challenge",
|
||||
Description: "Create a new challenge to get access session.",
|
||||
Requirements: wire.CommandRequirements{},
|
||||
Handle: startChallenge,
|
||||
},
|
||||
"passport.auth.challenges.do": {
|
||||
Name: "Challenge a challenge",
|
||||
Description: "Getting closer to get access session.",
|
||||
Requirements: wire.CommandRequirements{},
|
||||
Handle: doChallenge,
|
||||
},
|
||||
"passport.auth.factor.token": {
|
||||
Name: "Get a factor token",
|
||||
Description: "Get the factor token to finish the challenge.",
|
||||
Requirements: wire.CommandRequirements{},
|
||||
Handle: getFactorToken,
|
||||
},
|
||||
"passport.auth.tokens.exchange": {
|
||||
Name: "Exchange a pair of token",
|
||||
Description: "Use the grant token to exchange the first token pair.",
|
||||
Requirements: wire.CommandRequirements{},
|
||||
Handle: exchangeToken,
|
||||
},
|
||||
"passport.auth.tokens.refresh": {
|
||||
Name: "Refresh a pair token",
|
||||
Description: "Use the refresh token to refresh the token pair.",
|
||||
Requirements: wire.CommandRequirements{},
|
||||
Handle: refreshToken,
|
||||
},
|
||||
}
|
39
pkg/server/startup.go
Normal file
39
pkg/server/startup.go
Normal file
@ -0,0 +1,39 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"code.smartsheep.studio/hydrogen/bus/pkg/kit/publisher"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
Hostname = "hydrogen.passport"
|
||||
Namespace = "passport"
|
||||
)
|
||||
|
||||
var C *publisher.PublisherConnection
|
||||
|
||||
func InitConnection(addr, id string) error {
|
||||
if conn, err := publisher.NewConnection(
|
||||
addr,
|
||||
id,
|
||||
Hostname,
|
||||
Namespace,
|
||||
viper.Get("credentials"),
|
||||
); err != nil {
|
||||
return err
|
||||
} else {
|
||||
C = conn
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func PublishCommands(conn *publisher.PublisherConnection) error {
|
||||
for k, v := range Commands {
|
||||
if err := conn.PublishCommand(k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
27
pkg/services/accounts.go
Normal file
27
pkg/services/accounts.go
Normal file
@ -0,0 +1,27 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/database"
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/models"
|
||||
)
|
||||
|
||||
func LookupAccount(id string) (models.Account, error) {
|
||||
var account models.Account
|
||||
if err := database.C.Where(models.Account{Name: id}).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.Account{
|
||||
BaseModel: models.BaseModel{ID: contact.AccountID},
|
||||
}).First(&contact).Error; err == nil {
|
||||
return account, err
|
||||
}
|
||||
}
|
||||
|
||||
return account, fmt.Errorf("account was not found")
|
||||
}
|
26
pkg/services/challanges.go
Normal file
26
pkg/services/challanges.go
Normal file
@ -0,0 +1,26 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/database"
|
||||
"code.smartsheep.studio/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
|
||||
}
|
24
pkg/services/factors.go
Normal file
24
pkg/services/factors.go
Normal file
@ -0,0 +1,24 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/database"
|
||||
"code.smartsheep.studio/hydrogen/passport/pkg/models"
|
||||
)
|
||||
|
||||
func LookupFactor(id uint) (models.AuthFactor, error) {
|
||||
var factor models.AuthFactor
|
||||
err := database.C.Where(models.AuthFactor{
|
||||
BaseModel: models.BaseModel{ID: id},
|
||||
}).First(&factor).Error
|
||||
|
||||
return factor, err
|
||||
}
|
||||
|
||||
func LookupFactorsByUser(uid uint) ([]models.AuthFactor, error) {
|
||||
var factors []models.AuthFactor
|
||||
err := database.C.Where(models.AuthFactor{
|
||||
AccountID: uid,
|
||||
}).Find(&factors).Error
|
||||
|
||||
return factors, err
|
||||
}
|
Reference in New Issue
Block a user