✨ New ticket ways
This commit is contained in:
parent
0d78f34535
commit
87cccefddb
20
.idea/dataSources.local.xml
Normal file
20
.idea/dataSources.local.xml
Normal 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>"</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
Normal file
12
.idea/dataSources.xml
Normal 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>
|
5716
.idea/dataSources/74bcf3ef-a2b9-435b-b9e5-f32902a33b25.xml
Normal file
5716
.idea/dataSources/74bcf3ef-a2b9-435b-b9e5-f32902a33b25.xml
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1 @@
|
|||||||
|
#n:hy_passport
|
@ -0,0 +1,2 @@
|
|||||||
|
#n:public
|
||||||
|
!<md> [6258, 0, null, null, -2147483648, -2147483648]
|
@ -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">{
|
||||||
|
"customColor": "",
|
||||||
|
"associatedIndex": 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>
|
||||||
|
@ -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{},
|
||||||
|
@ -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"`
|
||||||
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
@ -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"`
|
||||||
|
@ -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
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
145
pkg/server/auth_api.go
Normal 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(),
|
||||||
|
})
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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(),
|
|
||||||
})
|
|
||||||
}
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
@ -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:
|
||||||
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
|
@ -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),
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
package security
|
package services
|
||||||
|
|
||||||
import "golang.org/x/crypto/bcrypt"
|
import "golang.org/x/crypto/bcrypt"
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
package security
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
@ -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
136
pkg/services/ticket.go
Normal 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
|
||||||
|
}
|
29
pkg/services/ticket_queries.go
Normal file
29
pkg/services/ticket_queries.go
Normal 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
|
||||||
|
}
|
98
pkg/services/ticket_token.go
Normal file
98
pkg/services/ticket_token.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user