New ticket ways

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

20
.idea/dataSources.local.xml generated Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="dataSourceStorageLocal" created-in="GO-241.14494.238">
<data-source name="hy_passport@localhost" uuid="74bcf3ef-a2b9-435b-b9e5-f32902a33b25">
<database-info product="PostgreSQL" version="16.2 (Homebrew)" jdbc-version="4.2" driver-name="PostgreSQL JDBC Driver" driver-version="42.6.0" dbms="POSTGRES" exact-version="16.2" exact-driver-version="42.6">
<identifier-quote-string>&quot;</identifier-quote-string>
</database-info>
<case-sensitivity plain-identifiers="lower" quoted-identifiers="exact" />
<secret-storage>master_key</secret-storage>
<user-name>postgres</user-name>
<schema-mapping>
<introspection-scope>
<node kind="database" qname="@">
<node kind="schema" qname="@" />
</node>
</introspection-scope>
</schema-mapping>
</data-source>
</component>
</project>

12
.idea/dataSources.xml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="hy_passport@localhost" uuid="74bcf3ef-a2b9-435b-b9e5-f32902a33b25">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/hy_passport</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
#n:public
!<md> [6258, 0, null, null, -2147483648, -2147483648]

96
.idea/workspace.xml generated
View File

@ -4,73 +4,12 @@
<option name="autoReloadType" value="ALL" /> <option name="autoReloadType" value="ALL" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="3fefb2c4-b6f9-466b-a523-53352e8d6f95" name="更改" comment=""> <list default="true" id="3fefb2c4-b6f9-466b-a523-53352e8d6f95" name="更改" comment=":sparkles: New ticket ways">
<change afterPath="$PROJECT_DIR$/pkg/embed.go" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/pkg/i18n/bundle.go" afterDir="false" /> <change beforePath="$PROJECT_DIR$/pkg/server/oauth_api.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/server/oauth_api.go" afterDir="false" />
<change afterPath="$PROJECT_DIR$/pkg/i18n/embed.go" afterDir="false" /> <change beforePath="$PROJECT_DIR$/pkg/server/security_api.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/server/security_api.go" afterDir="false" />
<change afterPath="$PROJECT_DIR$/pkg/i18n/locale.en.json" afterDir="false" />
<change afterPath="$PROJECT_DIR$/pkg/i18n/locale.zh.json" afterDir="false" />
<change afterPath="$PROJECT_DIR$/pkg/i18n/middleware.go" afterDir="false" />
<change afterPath="$PROJECT_DIR$/pkg/server/ui/auth.go" afterDir="false" />
<change afterPath="$PROJECT_DIR$/pkg/server/ui/index.go" afterDir="false" />
<change afterPath="$PROJECT_DIR$/pkg/views/index.gohtml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/pkg/views/layouts/auth.gohtml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/pkg/views/partials/header.gohtml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/pkg/views/signin.gohtml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/.gitignore" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/.name" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/Identity.iml" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/codeStyles/Project.xml" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/codeStyles/codeStyleConfig.xml" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/dataSources.xml" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/modules.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/modules.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/go.mod" beforeDir="false" afterPath="$PROJECT_DIR$/go.mod" afterDir="false" />
<change beforePath="$PROJECT_DIR$/go.sum" beforeDir="false" afterPath="$PROJECT_DIR$/go.sum" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/cmd/main.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/cmd/main.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/meta.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/meta.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/server/startup.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/server/startup.go" afterDir="false" /> <change beforePath="$PROJECT_DIR$/pkg/server/startup.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/server/startup.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/server/userinfo..go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/server/userinfo.go" afterDir="false" /> <change beforePath="$PROJECT_DIR$/pkg/services/ticket.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/services/ticket.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/.eslintrc.cjs" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/.eslintrc.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/.gitignore" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/.prettierrc.json" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/.vscode/extensions.json" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/README.md" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/embed.go" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/env.d.ts" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/index.html" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/package.json" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/public/favicon.png" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/assets/utils.css" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/components/Copyright.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/components/NotificationList.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/components/UserMenu.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/components/auth/AccountLocator.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/components/auth/CallbackNotify.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/components/auth/FactorApplicator.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/components/auth/FactorPicker.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/index.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/layouts/master.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/layouts/user-center.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/main.ts" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/router/index.ts" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/scripts/request.ts" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/stores/notifications.ts" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/stores/userinfo.ts" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/views/auth/claims.ts" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/views/auth/connect.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/views/auth/sign-in.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/views/auth/sign-up.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/views/confirm.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/views/dashboard.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/views/personal-page.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/views/personalize.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/src/views/security.vue" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/tsconfig.app.json" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/tsconfig.json" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/tsconfig.node.json" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/uno.config.ts" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/views/vite.config.ts" beforeDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -93,10 +32,13 @@
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component> </component>
<component name="ProjectColorInfo"><![CDATA[{ <component name="ProblemsViewState">
"customColor": "", <option name="selectedTabId" value="ProjectErrors" />
"associatedIndex": 6 </component>
}]]></component> <component name="ProjectColorInfo">{
&quot;customColor&quot;: &quot;&quot;,
&quot;associatedIndex&quot;: 6
}</component>
<component name="ProjectId" id="2fLXu43fjlLYVIGNrhGhOgBFq2O" /> <component name="ProjectId" id="2fLXu43fjlLYVIGNrhGhOgBFq2O" />
<component name="ProjectViewState"> <component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
@ -118,14 +60,23 @@
"node.js.detected.package.eslint": "true", "node.js.detected.package.eslint": "true",
"node.js.selected.package.eslint": "(autodetect)", "node.js.selected.package.eslint": "(autodetect)",
"nodejs_package_manager_path": "npm", "nodejs_package_manager_path": "npm",
"run.code.analysis.last.selected.profile": "pProject Default",
"settings.editor.selected.configurable": "preferences.lookFeel", "settings.editor.selected.configurable": "preferences.lookFeel",
"vue.rearranger.settings.migration": "true" "vue.rearranger.settings.migration": "true"
},
"keyToStringList": {
"DatabaseDriversLRU": [
"postgresql"
]
} }
}]]></component> }]]></component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/pkg" /> <recent name="$PROJECT_DIR$/pkg" />
</key> </key>
<key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/pkg/services" />
</key>
</component> </component>
<component name="RunManager"> <component name="RunManager">
<configuration name="Backend" type="GoApplicationRunConfiguration" factoryName="Go Application"> <configuration name="Backend" type="GoApplicationRunConfiguration" factoryName="Go Application">
@ -161,6 +112,11 @@
</map> </map>
</option> </option>
</component> </component>
<component name="VcsManagerConfiguration">
<MESSAGE value=":recycle: Refactor frontend" />
<MESSAGE value=":sparkles: New ticket ways" />
<option name="LAST_COMMIT_MESSAGE" value=":sparkles: New ticket ways" />
</component>
<component name="VgoProject"> <component name="VgoProject">
<settings-migrated>true</settings-migrated> <settings-migrated>true</settings-migrated>
</component> </component>

View File

@ -12,8 +12,7 @@ var DatabaseAutoActionRange = []any{
&models.AccountPage{}, &models.AccountPage{},
&models.AccountContact{}, &models.AccountContact{},
&models.AccountFriendship{}, &models.AccountFriendship{},
&models.AuthSession{}, &models.AuthTicket{},
&models.AuthChallenge{},
&models.MagicToken{}, &models.MagicToken{},
&models.ThirdClient{}, &models.ThirdClient{},
&models.ActionEvent{}, &models.ActionEvent{},

View File

@ -23,9 +23,8 @@ type Account struct {
PersonalPage AccountPage `json:"personal_page"` PersonalPage AccountPage `json:"personal_page"`
Contacts []AccountContact `json:"contacts"` Contacts []AccountContact `json:"contacts"`
Sessions []AuthSession `json:"sessions"` Sessions []AuthTicket `json:"sessions"`
Challenges []AuthChallenge `json:"challenges"` Factors []AuthFactor `json:"factors"`
Factors []AuthFactor `json:"factors"`
Events []ActionEvent `json:"events"` Events []ActionEvent `json:"events"`
MagicTokens []MagicToken `json:"-" gorm:"foreignKey:AssignTo"` MagicTokens []MagicToken `json:"-" gorm:"foreignKey:AssignTo"`

View File

@ -23,23 +23,30 @@ type AuthFactor struct {
AccountID uint `json:"account_id"` AccountID uint `json:"account_id"`
} }
type AuthSession struct { type AuthTicket struct {
BaseModel BaseModel
Claims datatypes.JSONSlice[string] `json:"claims"` Location string `json:"location"`
Audiences datatypes.JSONSlice[string] `json:"audiences"` IpAddress string `json:"ip_address"`
Challenge AuthChallenge `json:"challenge" gorm:"foreignKey:SessionID"` UserAgent string `json:"user_agent"`
GrantToken string `json:"grant_token"` RequireMFA bool `json:"require_mfa"`
AccessToken string `json:"access_token"` RequireAuthenticate bool `json:"require_authenticate"`
RefreshToken string `json:"refresh_token"` Claims datatypes.JSONSlice[string] `json:"claims"`
ExpiredAt *time.Time `json:"expired_at"` Audiences datatypes.JSONSlice[string] `json:"audiences"`
AvailableAt *time.Time `json:"available_at"` GrantToken *string `json:"grant_token"`
LastGrantAt *time.Time `json:"last_grant_at"` AccessToken *string `json:"access_token"`
ClientID *uint `json:"client_id"` RefreshToken *string `json:"refresh_token"`
AccountID uint `json:"account_id"` ExpiredAt *time.Time `json:"expired_at"`
AvailableAt *time.Time `json:"available_at"`
LastGrantAt *time.Time `json:"last_grant_at"`
ClientID *uint `json:"client_id"`
AccountID uint `json:"account_id"`
} }
func (v AuthSession) IsAvailable() error { func (v AuthTicket) IsAvailable() error {
if v.RequireMFA || v.RequireAuthenticate {
return fmt.Errorf("session isn't authenticated yet")
}
if v.AvailableAt != nil && time.Now().Unix() < v.AvailableAt.Unix() { if v.AvailableAt != nil && time.Now().Unix() < v.AvailableAt.Unix() {
return fmt.Errorf("session isn't available yet") return fmt.Errorf("session isn't available yet")
} }
@ -50,40 +57,8 @@ func (v AuthSession) IsAvailable() error {
return nil return nil
} }
type AuthChallengeState = int8
const (
ActiveChallengeState = AuthChallengeState(iota)
ExpiredChallengeState
FinishChallengeState
)
type AuthChallenge struct {
BaseModel
Location string `json:"location"`
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
}
type AuthContext struct { type AuthContext struct {
Session AuthSession `json:"session"` Ticket AuthTicket `json:"session"`
Account Account `json:"account"` Account Account `json:"account"`
ExpiredAt time.Time `json:"expired_at"` ExpiredAt time.Time `json:"expired_at"`
} }

View File

@ -11,7 +11,7 @@ type ThirdClient struct {
Secret string `json:"secret"` Secret string `json:"secret"`
Urls datatypes.JSONSlice[string] `json:"urls"` Urls datatypes.JSONSlice[string] `json:"urls"`
Callbacks datatypes.JSONSlice[string] `json:"callbacks"` Callbacks datatypes.JSONSlice[string] `json:"callbacks"`
Sessions []AuthSession `json:"sessions" gorm:"foreignKey:ClientID"` Sessions []AuthTicket `json:"sessions" gorm:"foreignKey:ClientID"`
Notifications []Notification `json:"notifications" gorm:"foreignKey:SenderID"` Notifications []Notification `json:"notifications" gorm:"foreignKey:SenderID"`
IsDraft bool `json:"is_draft"` IsDraft bool `json:"is_draft"`
AccountID *uint `json:"account_id"` AccountID *uint `json:"account_id"`

View File

@ -1,96 +0,0 @@
package security
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
"git.solsynth.dev/hydrogen/passport/pkg/database"
"git.solsynth.dev/hydrogen/passport/pkg/models"
"github.com/samber/lo"
"gorm.io/datatypes"
)
func CalcRisk(user models.Account, ip, ua string) int {
risk := 3
var secureFactor int64
if err := database.C.Where(models.AuthChallenge{
AccountID: user.ID,
IpAddress: ip,
}).Model(models.AuthChallenge{}).Count(&secureFactor).Error; err == nil {
if secureFactor >= 3 {
risk -= 3
} else if secureFactor >= 1 {
risk -= 2
}
}
return risk
}
func NewChallenge(user models.Account, factors []models.AuthFactor, ip, ua string) (models.AuthChallenge, error) {
var challenge models.AuthChallenge
// Pickup any challenge if possible
if err := database.C.Where(models.AuthChallenge{
AccountID: user.ID,
}).Where("state = ?", models.ActiveChallengeState).First(&challenge).Error; err == nil {
return challenge, nil
}
// Calculate the risk level
risk := CalcRisk(user, ip, ua)
// Clamp risk in the exists requirements factor count
requirements := lo.Clamp(risk, 1, len(factors))
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: user.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 {
challenge.State = models.ExpiredChallengeState
database.C.Save(&challenge)
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
}
// Revoke some factor passwords
if factor.Type == models.EmailPasswordFactor {
factor.Secret = strings.ReplaceAll(uuid.NewString(), "-", "")
database.C.Save(&factor)
}
return nil
}

View File

@ -1,27 +0,0 @@
package security
import (
"fmt"
"git.solsynth.dev/hydrogen/passport/pkg/models"
"github.com/samber/lo"
)
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"),
)
case models.EmailPasswordFactor:
return lo.Ternary(
code == factor.Secret,
nil,
fmt.Errorf("invalid verification code"),
)
}
return nil
}

View File

@ -1,165 +0,0 @@
package security
import (
"fmt"
"strconv"
"time"
"github.com/spf13/viper"
"git.solsynth.dev/hydrogen/passport/pkg/database"
"git.solsynth.dev/hydrogen/passport/pkg/models"
"github.com/google/uuid"
"github.com/samber/lo"
)
func GrantSession(challenge models.AuthChallenge, claims, audiences []string, expired, 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,
Audiences: audiences,
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 GrantOauthSession(user models.Account, client models.ThirdClient, claims, audiences []string, expired, available *time.Time, ip, ua string) (models.AuthSession, error) {
session := models.AuthSession{
Claims: claims,
Audiences: audiences,
Challenge: models.AuthChallenge{
IpAddress: ip,
UserAgent: ua,
RiskLevel: CalcRisk(user, ip, ua),
State: models.FinishChallengeState,
AccountID: user.ID,
},
GrantToken: uuid.NewString(),
AccessToken: uuid.NewString(),
RefreshToken: uuid.NewString(),
ExpiredAt: expired,
AvailableAt: available,
ClientID: &client.ID,
AccountID: user.ID,
}
if err := database.C.Save(&session).Error; err != nil {
return session, err
}
return session, nil
}
func RegenSession(session models.AuthSession) (models.AuthSession, error) {
session.GrantToken = uuid.NewString()
session.AccessToken = uuid.NewString()
session.RefreshToken = uuid.NewString()
err := database.C.Save(&session).Error
return session, err
}
func GetToken(session models.AuthSession) (string, string, error) {
var refresh, access string
if err := session.IsAvailable(); err != nil {
return refresh, access, err
}
accessDuration := time.Duration(viper.GetInt64("security.access_token_duration")) * time.Second
refreshDuration := time.Duration(viper.GetInt64("security.refresh_token_duration")) * time.Second
var err error
sub := strconv.Itoa(int(session.AccountID))
sed := strconv.Itoa(int(session.ID))
access, err = EncodeJwt(session.AccessToken, JwtAccessType, sub, sed, session.Audiences, time.Now().Add(accessDuration))
if err != nil {
return refresh, access, err
}
refresh, err = EncodeJwt(session.RefreshToken, JwtRefreshType, sub, sed, session.Audiences, time.Now().Add(refreshDuration))
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) (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")
} else if len(session.Audiences) > 1 {
return "404", "403", fmt.Errorf("should use authorization code grant type")
}
return GetToken(session)
}
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 "404", "403", err
} else if client.Secret != clientSecret {
return "404", "403", fmt.Errorf("invalid client secret")
} else if !client.IsDraft && !lo.Contains(client.Callbacks, redirectUri) {
return "404", "403", fmt.Errorf("invalid redirect uri")
}
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)
}
func RefreshToken(token 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.SessionID))},
}).First(&session).Error; err != nil {
return "404", "403", err
}
if session, err := RegenSession(session); err != nil {
return "404", "403", err
} else {
return GetToken(session)
}
}

View File

@ -114,7 +114,7 @@ func killSession(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account) user := c.Locals("principal").(models.Account)
id, _ := c.ParamsInt("sessionId", 0) id, _ := c.ParamsInt("sessionId", 0)
if err := database.C.Delete(&models.AuthSession{}, &models.AuthSession{ if err := database.C.Delete(&models.AuthTicket{}, &models.AuthTicket{
BaseModel: models.BaseModel{ID: uint(id)}, BaseModel: models.BaseModel{ID: uint(id)},
AccountID: user.ID, AccountID: user.ID,
}).Error; err != nil { }).Error; err != nil {

145
pkg/server/auth_api.go Normal file
View File

@ -0,0 +1,145 @@
package server
import (
"fmt"
"time"
"github.com/gofiber/fiber/v2"
"git.solsynth.dev/hydrogen/passport/pkg/services"
)
func doAuthenticate(c *fiber.Ctx) error {
var data struct {
Username string `json:"username"`
Password string `json:"password" validate:"required"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
user, err := services.LookupAccount(data.Username)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("account was not found: %v", err.Error()))
}
ticket, err := services.NewTicket(user, c.IP(), c.Get(fiber.HeaderUserAgent))
if err != nil {
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("invalid password: %v", err.Error()))
}
return c.JSON(fiber.Map{
"is_finished": ticket.IsAvailable(),
"ticket": ticket,
})
}
func doMultiFactorAuthenticate(c *fiber.Ctx) error {
var data struct {
TicketID uint `json:"ticket_id" validate:"required"`
FactorID uint `json:"factor_id" validate:"required"`
Code string `json:"code" validate:"required"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
ticket, err := services.GetTicket(data.TicketID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("ticket was not found: %v", err.Error()))
}
factor, err := services.GetFactor(data.FactorID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("factor was not found: %v", err.Error()))
}
ticket, err = services.ActiveTicketWithMFA(ticket, factor, data.Code)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("invalid code: %v", err.Error()))
}
return c.JSON(fiber.Map{
"is_finished": ticket.IsAvailable(),
"ticket": ticket,
})
}
func getToken(c *fiber.Ctx) error {
var data struct {
Code string `json:"code" form:"code"`
RefreshToken string `json:"refresh_token" form:"refresh_token"`
ClientID string `json:"client_id" form:"client_id"`
ClientSecret string `json:"client_secret" form:"client_secret"`
Username string `json:"username" form:"username"`
Password string `json:"password" form:"password"`
RedirectUri string `json:"redirect_uri" form:"redirect_uri"`
GrantType string `json:"grant_type" form:"grant_type"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
var err error
var access, refresh string
switch data.GrantType {
case "refresh_token":
// Refresh Token
access, refresh, err = services.RefreshToken(data.RefreshToken)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
case "authorization_code":
// Authorization Code Mode
access, refresh, err = services.ExchangeOauthToken(data.ClientID, data.ClientSecret, data.RedirectUri, data.Code)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
case "password":
// Password Mode
user, err := services.LookupAccount(data.Username)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("account was not found: %v", err.Error()))
}
ticket, err := services.NewTicket(user, c.IP(), c.Get(fiber.HeaderUserAgent))
if err != nil {
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("invalid password: %v", err.Error()))
} else if ticket.GrantToken == nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to get grant token to get token"))
}
access, refresh, err = services.ExchangeOauthToken(data.ClientID, data.ClientSecret, data.RedirectUri, *ticket.GrantToken)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
case "grant_token":
// Internal Usage
access, refresh, err = services.ExchangeToken(data.Code)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
default:
return fiber.NewError(fiber.StatusBadRequest, "unsupported exchange token type")
}
services.SetJwtCookieSet(c, access, refresh)
return c.JSON(fiber.Map{
"id_token": access,
"access_token": access,
"refresh_token": refresh,
"token_type": "Bearer",
"expires_in": (30 * time.Minute).Seconds(),
})
}

View File

@ -3,14 +3,13 @@ package server
import ( import (
"strings" "strings"
"git.solsynth.dev/hydrogen/passport/pkg/security"
"git.solsynth.dev/hydrogen/passport/pkg/services" "git.solsynth.dev/hydrogen/passport/pkg/services"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
func authMiddleware(c *fiber.Ctx) error { func authMiddleware(c *fiber.Ctx) error {
var token string var token string
if cookie := c.Cookies(security.CookieAccessKey); len(cookie) > 0 { if cookie := c.Cookies(services.CookieAccessKey); len(cookie) > 0 {
token = cookie token = cookie
} }
if header := c.Get(fiber.HeaderAuthorization); len(header) > 0 { if header := c.Get(fiber.HeaderAuthorization); len(header) > 0 {
@ -42,10 +41,10 @@ func authFunc(c *fiber.Ctx, overrides ...string) error {
} }
} }
rtk := c.Cookies(security.CookieRefreshKey) rtk := c.Cookies(services.CookieRefreshKey)
if user, atk, rtk, err := services.Authenticate(token, rtk, 0); err == nil { if user, atk, rtk, err := services.Authenticate(token, rtk, 0); err == nil {
if atk != token { if atk != token {
security.SetJwtCookieSet(c, atk, rtk) services.SetJwtCookieSet(c, atk, rtk)
} }
c.Locals("principal", user) c.Locals("principal", user)
return nil return nil

View File

@ -1,140 +0,0 @@
package server
import (
"time"
"github.com/gofiber/fiber/v2"
"git.solsynth.dev/hydrogen/passport/pkg/security"
"git.solsynth.dev/hydrogen/passport/pkg/services"
"github.com/samber/lo"
)
func startChallenge(c *fiber.Ctx) error {
var data struct {
ID string `json:"id" validate:"required"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
user, err := services.LookupAccount(data.ID)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
factors, err := services.LookupFactorsByUser(user.ID)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
challenge, err := security.NewChallenge(user, factors, c.IP(), c.Get(fiber.HeaderUserAgent))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
services.AddEvent(user, "challenges.start", data.ID, c.IP(), c.Get(fiber.HeaderUserAgent))
return c.JSON(fiber.Map{
"display_name": user.Nick,
"challenge": challenge,
"factors": factors,
})
}
func doChallenge(c *fiber.Ctx) error {
var data struct {
ChallengeID uint `json:"challenge_id" validate:"required"`
FactorID uint `json:"factor_id" validate:"required"`
Secret string `json:"secret" validate:"required"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
challenge, err := services.LookupChallengeWithFingerprint(data.ChallengeID, c.IP(), c.Get(fiber.HeaderUserAgent))
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
factor, err := services.LookupFactor(data.FactorID)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if err := security.DoChallenge(challenge, factor, data.Secret); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
challenge, err = services.LookupChallenge(data.ChallengeID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else if challenge.Progress >= challenge.Requirements {
session, err := security.GrantSession(challenge, []string{"*"}, []string{"passport"}, nil, lo.ToPtr(time.Now()))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.JSON(fiber.Map{
"is_finished": true,
"challenge": challenge,
"session": session,
})
}
return c.JSON(fiber.Map{
"is_finished": false,
"challenge": challenge,
"session": nil,
})
}
func exchangeToken(c *fiber.Ctx) error {
var data struct {
Code string `json:"code" form:"code"`
RefreshToken string `json:"refresh_token" form:"refresh_token"`
ClientID string `json:"client_id" form:"client_id"`
ClientSecret string `json:"client_secret" form:"client_secret"`
RedirectUri string `json:"redirect_uri" form:"redirect_uri"`
GrantType string `json:"grant_type" form:"grant_type"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
}
var err error
var access, refresh string
switch data.GrantType {
case "authorization_code":
// Authorization Code Mode
access, refresh, err = security.ExchangeOauthToken(data.ClientID, data.ClientSecret, data.RedirectUri, data.Code)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
case "grant_token":
// Internal Usage
access, refresh, err = security.ExchangeToken(data.Code)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
case "refresh_token":
// Refresh Token
access, refresh, err = security.RefreshToken(data.RefreshToken)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
default:
return fiber.NewError(fiber.StatusBadRequest, "unsupported exchange token type")
}
security.SetJwtCookieSet(c, access, refresh)
return c.JSON(fiber.Map{
"id_token": access,
"access_token": access,
"refresh_token": refresh,
"token_type": "Bearer",
"expires_in": (30 * time.Minute).Seconds(),
})
}

View File

@ -8,7 +8,7 @@ import (
func requestFactorToken(c *fiber.Ctx) error { func requestFactorToken(c *fiber.Ctx) error {
id, _ := c.ParamsInt("factorId", 0) id, _ := c.ParamsInt("factorId", 0)
factor, err := services.LookupFactor(uint(id)) factor, err := services.GetFactor(uint(id))
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error()) return fiber.NewError(fiber.StatusNotFound, err.Error())
} }

View File

@ -6,7 +6,6 @@ import (
"git.solsynth.dev/hydrogen/passport/pkg/database" "git.solsynth.dev/hydrogen/passport/pkg/database"
"git.solsynth.dev/hydrogen/passport/pkg/models" "git.solsynth.dev/hydrogen/passport/pkg/models"
"git.solsynth.dev/hydrogen/passport/pkg/security"
"git.solsynth.dev/hydrogen/passport/pkg/services" "git.solsynth.dev/hydrogen/passport/pkg/services"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/samber/lo" "github.com/samber/lo"
@ -29,8 +28,8 @@ func preConnect(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account) user := c.Locals("principal").(models.Account)
var session models.AuthSession var session models.AuthTicket
if err := database.C.Where(&models.AuthSession{ if err := database.C.Where(&models.AuthTicket{
AccountID: user.ID, AccountID: user.ID,
ClientID: &client.ID, ClientID: &client.ID,
}).Where("last_grant_at IS NULL").First(&session).Error; err == nil { }).Where("last_grant_at IS NULL").First(&session).Error; err == nil {
@ -40,7 +39,7 @@ func preConnect(c *fiber.Ctx) error {
"session": nil, "session": nil,
}) })
} else { } else {
session, err = security.RegenSession(session) session, err = services.RegenSession(session)
} }
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
@ -73,13 +72,11 @@ func doConnect(c *fiber.Ctx) error {
switch response { switch response {
case "code": case "code":
// OAuth Authorization Mode // OAuth Authorization Mode
session, err := security.GrantOauthSession( ticket, err := services.NewOauthTicket(
user, user,
client, client,
strings.Split(scope, " "), strings.Split(scope, " "),
[]string{"passport", client.Alias}, []string{"passport", client.Alias},
nil,
lo.ToPtr(time.Now()),
c.IP(), c.IP(),
c.Get(fiber.HeaderUserAgent), c.Get(fiber.HeaderUserAgent),
) )
@ -89,26 +86,24 @@ func doConnect(c *fiber.Ctx) error {
} else { } else {
services.AddEvent(user, "oauth.connect", client.Alias, c.IP(), c.Get(fiber.HeaderUserAgent)) services.AddEvent(user, "oauth.connect", client.Alias, c.IP(), c.Get(fiber.HeaderUserAgent))
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"session": session, "session": ticket,
"redirect_uri": redirect, "redirect_uri": redirect,
}) })
} }
case "token": case "token":
// OAuth Implicit Mode // OAuth Implicit Mode
session, err := security.GrantOauthSession( ticket, err := services.NewOauthTicket(
user, user,
client, client,
strings.Split(scope, " "), strings.Split(scope, " "),
[]string{"passport", client.Alias}, []string{"passport", client.Alias},
nil,
lo.ToPtr(time.Now()),
c.IP(), c.IP(),
c.Get(fiber.HeaderUserAgent), c.Get(fiber.HeaderUserAgent),
) )
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error()) return fiber.NewError(fiber.StatusInternalServerError, err.Error())
} else if access, refresh, err := security.GetToken(session); err != nil { } else if access, refresh, err := services.GetToken(ticket); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error()) return fiber.NewError(fiber.StatusInternalServerError, err.Error())
} else { } else {
services.AddEvent(user, "oauth.connect", client.Alias, c.IP(), c.Get(fiber.HeaderUserAgent)) services.AddEvent(user, "oauth.connect", client.Alias, c.IP(), c.Get(fiber.HeaderUserAgent))
@ -116,7 +111,7 @@ func doConnect(c *fiber.Ctx) error {
"access_token": access, "access_token": access,
"refresh_token": refresh, "refresh_token": refresh,
"redirect_uri": redirect, "redirect_uri": redirect,
"session": session, "ticket": ticket,
}) })
} }
default: default:

View File

@ -6,52 +6,23 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
func getChallenges(c *fiber.Ctx) error { func getTickets(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account) user := c.Locals("principal").(models.Account)
take := c.QueryInt("take", 0) take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0) offset := c.QueryInt("offset", 0)
var count int64 var count int64
var challenges []models.AuthChallenge var sessions []models.AuthTicket
if err := database.C. if err := database.C.
Where(&models.AuthChallenge{AccountID: user.ID}). Where(&models.AuthTicket{AccountID: user.ID}).
Model(&models.AuthChallenge{}). Model(&models.AuthTicket{}).
Count(&count).Error; err != nil { Count(&count).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error()) return fiber.NewError(fiber.StatusInternalServerError, err.Error())
} }
if err := database.C. if err := database.C.
Order("created_at desc"). Order("created_at desc").
Where(&models.AuthChallenge{AccountID: user.ID}). Where(&models.AuthTicket{AccountID: user.ID}).
Limit(take).
Offset(offset).
Find(&challenges).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(fiber.Map{
"count": count,
"data": challenges,
})
}
func getSessions(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
take := c.QueryInt("take", 0)
offset := c.QueryInt("offset", 0)
var count int64
var sessions []models.AuthSession
if err := database.C.
Where(&models.AuthSession{AccountID: user.ID}).
Model(&models.AuthSession{}).
Count(&count).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
if err := database.C.
Order("created_at desc").
Where(&models.AuthSession{AccountID: user.ID}).
Limit(take). Limit(take).
Offset(offset). Offset(offset).
Find(&sessions).Error; err != nil { Find(&sessions).Error; err != nil {

View File

@ -87,8 +87,7 @@ func NewServer() {
me.Put("/", authMiddleware, editUserinfo) me.Put("/", authMiddleware, editUserinfo)
me.Put("/page", authMiddleware, editPersonalPage) me.Put("/page", authMiddleware, editPersonalPage)
me.Get("/events", authMiddleware, getEvents) me.Get("/events", authMiddleware, getEvents)
me.Get("/challenges", authMiddleware, getChallenges) me.Get("/tickets", authMiddleware, getTickets)
me.Get("/sessions", authMiddleware, getSessions)
me.Delete("/sessions/:sessionId", authMiddleware, killSession) me.Delete("/sessions/:sessionId", authMiddleware, killSession)
me.Post("/confirm", doRegisterConfirm) me.Post("/confirm", doRegisterConfirm)
@ -112,9 +111,8 @@ func NewServer() {
api.Post("/users", doRegister) api.Post("/users", doRegister)
api.Put("/auth", startChallenge) api.Post("/auth", doAuthenticate)
api.Post("/auth", doChallenge) api.Post("/auth/token", getToken)
api.Post("/auth/token", exchangeToken)
api.Post("/auth/factors/:factorId", requestFactorToken) api.Post("/auth/factors/:factorId", requestFactorToken)
api.Get("/auth/o/connect", authMiddleware, preConnect) api.Get("/auth/o/connect", authMiddleware, preConnect)

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package security package services
import "golang.org/x/crypto/bcrypt" import "golang.org/x/crypto/bcrypt"

View File

@ -2,6 +2,7 @@ package services
import ( import (
"fmt" "fmt"
"github.com/samber/lo"
"git.solsynth.dev/hydrogen/passport/pkg/database" "git.solsynth.dev/hydrogen/passport/pkg/database"
"git.solsynth.dev/hydrogen/passport/pkg/models" "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, Best regards,
%s` %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 var factor models.AuthFactor
err := database.C.Where(models.AuthFactor{ err := database.C.Where(models.AuthFactor{
BaseModel: models.BaseModel{ID: id}, BaseModel: models.BaseModel{ID: id},
@ -33,10 +44,10 @@ func LookupFactor(id uint) (models.AuthFactor, error) {
return factor, err return factor, err
} }
func LookupFactorsByUser(uid uint) ([]models.AuthFactor, error) { func ListUserFactor(userId uint) ([]models.AuthFactor, error) {
var factors []models.AuthFactor var factors []models.AuthFactor
err := database.C.Where(models.AuthFactor{ err := database.C.Where(models.AuthFactor{
AccountID: uid, AccountID: userId,
}).Find(&factors).Error }).Find(&factors).Error
return factors, err return factors, err
@ -68,3 +79,22 @@ func GetFactorCode(factor models.AuthFactor) (bool, error) {
return false, nil 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
}

View File

@ -1,4 +1,4 @@
package security package services
import ( import (
"fmt" "fmt"

View File

@ -1,28 +1,15 @@
package services package services
import ( import (
"time"
"git.solsynth.dev/hydrogen/passport/pkg/database" "git.solsynth.dev/hydrogen/passport/pkg/database"
"git.solsynth.dev/hydrogen/passport/pkg/models" "git.solsynth.dev/hydrogen/passport/pkg/models"
jsoniter "github.com/json-iterator/go" jsoniter "github.com/json-iterator/go"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/spf13/viper" "github.com/spf13/viper"
"go.etcd.io/bbolt" "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() { func DoAutoSignoff() {
duration := time.Duration(viper.GetInt64("security.auto_signoff_duration")) * time.Second duration := time.Duration(viper.GetInt64("security.auto_signoff_duration")) * time.Second
divider := time.Now().Add(-duration) divider := time.Now().Add(-duration)
@ -31,7 +18,7 @@ func DoAutoSignoff() {
if tx := database.C. if tx := database.C.
Where("last_grant_at < ?", divider). 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...") log.Error().Err(tx.Error).Msg("An error occurred when running auto sign off...")
} else { } else {
log.Debug().Int64("affected", tx.RowsAffected).Msg("Auto sign off accomplished.") 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)
}
}