diff --git a/pkg/database/migrator.go b/pkg/database/migrator.go index a2d5bfb..2333f18 100644 --- a/pkg/database/migrator.go +++ b/pkg/database/migrator.go @@ -14,6 +14,7 @@ func RunMigration(source *gorm.DB) error { &models.AuthSession{}, &models.AuthChallenge{}, &models.MagicToken{}, + &models.ThirdClient{}, ); err != nil { return err } diff --git a/pkg/models/accounts.go b/pkg/models/accounts.go index 6977d06..e597edf 100644 --- a/pkg/models/accounts.go +++ b/pkg/models/accounts.go @@ -17,17 +17,18 @@ const ( type Account struct { BaseModel - Name string `json:"name" gorm:"uniqueIndex"` - Nick string `json:"nick"` - State AccountState `json:"state"` - Profile AccountProfile `json:"profile"` - Session []AuthSession `json:"sessions"` - Challenges []AuthChallenge `json:"challenges"` - Factors []AuthFactor `json:"factors"` - Contacts []AccountContact `json:"contacts"` - MagicTokens []MagicToken `json:"-" gorm:"foreignKey:AssignTo"` - ConfirmedAt *time.Time `json:"confirmed_at"` - Permissions datatypes.JSONType[[]string] `json:"permissions"` + Name string `json:"name" gorm:"uniqueIndex"` + Nick string `json:"nick"` + State AccountState `json:"state"` + Profile AccountProfile `json:"profile"` + Sessions []AuthSession `json:"sessions"` + Challenges []AuthChallenge `json:"challenges"` + Factors []AuthFactor `json:"factors"` + Contacts []AccountContact `json:"contacts"` + MagicTokens []MagicToken `json:"-" gorm:"foreignKey:AssignTo"` + ThirdClients []ThirdClient `json:"clients"` + ConfirmedAt *time.Time `json:"confirmed_at"` + Permissions datatypes.JSONType[[]string] `json:"permissions"` } func (v Account) GetPrimaryEmail() AccountContact { diff --git a/pkg/models/auth.go b/pkg/models/auth.go index 64baa89..76f51c9 100644 --- a/pkg/models/auth.go +++ b/pkg/models/auth.go @@ -27,6 +27,7 @@ type AuthSession struct { BaseModel Claims datatypes.JSONSlice[string] `json:"claims"` + Audiences datatypes.JSONSlice[string] `json:"audiences"` Challenge AuthChallenge `json:"challenge" gorm:"foreignKey:SessionID"` GrantToken string `json:"grant_token"` AccessToken string `json:"access_token"` @@ -34,6 +35,7 @@ type AuthSession struct { 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"` } @@ -59,6 +61,7 @@ const ( type AuthChallenge struct { BaseModel + Location string `json:"location"` IpAddress string `json:"ip_address"` UserAgent string `json:"user_agent"` RiskLevel int `json:"risk_level"` diff --git a/pkg/models/clients.go b/pkg/models/clients.go index 37bdfde..8227c89 100644 --- a/pkg/models/clients.go +++ b/pkg/models/clients.go @@ -1,4 +1,17 @@ package models -type OauthClients struct { +import "gorm.io/datatypes" + +type ThirdClient struct { + BaseModel + + Alias string `json:"alias" gorm:"uniqueIndex"` + Name string `json:"name"` + Description string `json:"description"` + Secret string `json:"secret"` + Urls datatypes.JSONSlice[string] `json:"urls"` + Callbacks datatypes.JSONSlice[string] `json:"callbacks"` + Sessions []AuthSession `json:"sessions" gorm:"foreignKey:ClientID"` + IsDraft bool `json:"is_draft"` + AccountID *uint `json:"account_id"` } diff --git a/pkg/security/challanges.go b/pkg/security/challanges.go index f396406..424f7b2 100644 --- a/pkg/security/challanges.go +++ b/pkg/security/challanges.go @@ -10,31 +10,36 @@ import ( "gorm.io/datatypes" ) -func NewChallenge(account models.Account, factors []models.AuthFactor, ip, ua string) (models.AuthChallenge, error) { +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 -= 2 + } else if secureFactor >= 1 { + risk -= 1 + } + } + + 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: account.ID, + AccountID: user.ID, }).Where("state = ?", models.ActiveChallengeState).First(&challenge).Error; err == nil { return challenge, nil } - // Reduce the risk level - var secureFactor int64 - if err := database.C.Where(models.AuthChallenge{ - AccountID: account.ID, - IpAddress: ip, - }).Model(models.AuthChallenge{}).Count(&secureFactor).Error; err != nil { - return challenge, err - } - if secureFactor >= 3 { - risk -= 2 - } else if secureFactor >= 1 { - risk -= 1 - } + // Calculate the risk level + risk := CalcRisk(user, ip, ua) - // Thinking of the requirements factors + // Clamp risk in the exists requirements factor count requirements := lo.Clamp(risk, 1, len(factors)) challenge = models.AuthChallenge{ @@ -45,7 +50,7 @@ func NewChallenge(account models.Account, factors []models.AuthFactor, ip, ua st BlacklistFactors: datatypes.NewJSONType([]uint{}), State: models.ActiveChallengeState, ExpiredAt: time.Now().Add(2 * time.Hour), - AccountID: account.ID, + AccountID: user.ID, } err := database.C.Save(&challenge).Error diff --git a/pkg/security/sessions.go b/pkg/security/sessions.go index 3f46ef2..bacb500 100644 --- a/pkg/security/sessions.go +++ b/pkg/security/sessions.go @@ -11,7 +11,7 @@ import ( "github.com/samber/lo" ) -func GrantSession(challenge models.AuthChallenge, claims []string, expired *time.Time, available *time.Time) (models.AuthSession, error) { +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 @@ -24,6 +24,7 @@ func GrantSession(challenge models.AuthChallenge, claims []string, expired *time session = models.AuthSession{ Claims: claims, + Audiences: audiences, Challenge: challenge, GrantToken: uuid.NewString(), AccessToken: uuid.NewString(), @@ -42,7 +43,42 @@ func GrantSession(challenge models.AuthChallenge, claims []string, expired *time return session, nil } -func GetToken(session models.AuthSession, aud ...string) (string, string, error) { +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 @@ -51,11 +87,11 @@ func GetToken(session models.AuthSession, aud ...string) (string, string, error) var err error sub := strconv.Itoa(int(session.ID)) - access, err = EncodeJwt(session.AccessToken, nil, JwtAccessType, sub, aud, time.Now().Add(30*time.Minute)) + access, err = EncodeJwt(session.AccessToken, nil, JwtAccessType, sub, session.Audiences, time.Now().Add(30*time.Minute)) if err != nil { return refresh, access, err } - refresh, err = EncodeJwt(session.RefreshToken, nil, JwtRefreshType, sub, aud, time.Now().Add(30*24*time.Hour)) + refresh, err = EncodeJwt(session.RefreshToken, nil, JwtRefreshType, sub, session.Audiences, time.Now().Add(30*24*time.Hour)) if err != nil { return refresh, access, err } @@ -66,7 +102,29 @@ func GetToken(session models.AuthSession, aud ...string) (string, string, error) return access, refresh, nil } -func ExchangeToken(token string, aud ...string) (string, string, error) { +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 @@ -74,10 +132,10 @@ func ExchangeToken(token string, aud ...string) (string, string, error) { return "404", "403", fmt.Errorf("session was granted the first token, use refresh token instead") } - return GetToken(session, aud...) + return GetToken(session) } -func RefreshToken(token string, aud ...string) (string, string, error) { +func RefreshToken(token string) (string, string, error) { parseInt := func(str string) int { val, _ := strconv.Atoi(str) return val @@ -94,5 +152,5 @@ func RefreshToken(token string, aud ...string) (string, string, error) { return "404", "403", err } - return GetToken(session, aud...) + return GetToken(session) } diff --git a/pkg/server/accounts_api.go b/pkg/server/accounts_api.go index 64498c0..47bc349 100644 --- a/pkg/server/accounts_api.go +++ b/pkg/server/accounts_api.go @@ -7,16 +7,20 @@ import ( "fmt" "github.com/gofiber/fiber/v2" "github.com/spf13/viper" - "gorm.io/gorm/clause" ) func getPrincipal(c *fiber.Ctx) error { user := c.Locals("principal").(models.Account) var data models.Account - if err := database.C.Where(&models.Account{ - BaseModel: models.BaseModel{ID: user.ID}, - }).Preload(clause.Associations).First(&data).Error; err != nil { + if err := database.C. + Where(&models.Account{BaseModel: models.BaseModel{ID: user.ID}}). + Preload("Profile"). + Preload("Contacts"). + Preload("Factors"). + Preload("Sessions"). + Preload("Challenges"). + First(&data).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } diff --git a/pkg/server/challanges_api.go b/pkg/server/challanges_api.go index 42404ae..4947850 100644 --- a/pkg/server/challanges_api.go +++ b/pkg/server/challanges_api.go @@ -68,7 +68,7 @@ func doChallenge(c *fiber.Ctx) error { if err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } else if challenge.Progress >= challenge.Requirements { - session, err := security.GrantSession(challenge, []string{"*"}, nil, lo.ToPtr(time.Now())) + session, err := security.GrantSession(challenge, []string{"*"}, []string{"Hydrogen.Passport"}, nil, lo.ToPtr(time.Now())) if err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } @@ -89,8 +89,11 @@ func doChallenge(c *fiber.Ctx) error { func exchangeToken(c *fiber.Ctx) error { var data struct { - Code string `json:"code"` - GrantType string `json:"grant_type"` + Code string `json:"code" form:"code"` + 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 { @@ -99,6 +102,18 @@ func exchangeToken(c *fiber.Ctx) error { 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()) + } + + return c.JSON(fiber.Map{ + "access_token": access, + "refresh_token": refresh, + }) + case "grant_token": + // Internal Usage access, refresh, err := security.ExchangeToken(data.Code) if err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) @@ -109,6 +124,7 @@ func exchangeToken(c *fiber.Ctx) error { "refresh_token": refresh, }) case "refresh_token": + // Refresh Token access, refresh, err := security.RefreshToken(data.Code) if err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) @@ -119,6 +135,6 @@ func exchangeToken(c *fiber.Ctx) error { "refresh_token": refresh, }) default: - return fiber.NewError(fiber.StatusBadRequest, "Unsupported exchange token type.") + return fiber.NewError(fiber.StatusBadRequest, "unsupported exchange token type") } } diff --git a/pkg/server/oauth_api.go b/pkg/server/oauth_api.go new file mode 100644 index 0000000..d4c0177 --- /dev/null +++ b/pkg/server/oauth_api.go @@ -0,0 +1,119 @@ +package server + +import ( + "code.smartsheep.studio/hydrogen/passport/pkg/database" + "code.smartsheep.studio/hydrogen/passport/pkg/models" + "code.smartsheep.studio/hydrogen/passport/pkg/security" + "github.com/gofiber/fiber/v2" + "github.com/samber/lo" + "strings" + "time" +) + +func preConnect(c *fiber.Ctx) error { + id := c.Query("client_id") + redirect := c.Query("redirect_uri") + + var client models.ThirdClient + if err := database.C.Where(&models.ThirdClient{Alias: id}).First(&client).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } else if !client.IsDraft && !lo.Contains(client.Callbacks, strings.Split(redirect, "?")[0]) { + return fiber.NewError(fiber.StatusBadRequest, "invalid request url") + } + + user := c.Locals("principal").(models.Account) + + var session models.AuthSession + if err := database.C.Where(&models.AuthSession{ + AccountID: user.ID, + ClientID: &client.ID, + }).First(&session).Error; err == nil { + if session.ExpiredAt != nil && session.ExpiredAt.Unix() < time.Now().Unix() { + return c.JSON(fiber.Map{ + "client": client, + "session": nil, + }) + } else { + session, err = security.RegenSession(session) + } + + return c.JSON(fiber.Map{ + "client": client, + "session": session, + }) + } + + return c.JSON(fiber.Map{ + "client": client, + "session": nil, + }) +} + +func doConnect(c *fiber.Ctx) error { + user := c.Locals("principal").(models.Account) + id := c.Query("client_id") + response := c.Query("response_type") + redirect := c.Query("redirect_uri") + scope := c.Query("scope") + if len(scope) <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "invalid request params") + } + + var client models.ThirdClient + if err := database.C.Where(&models.ThirdClient{Alias: id}).First(&client).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + + switch response { + case "code": + // OAuth Authorization Mode + expired := time.Now().Add(7 * 24 * time.Hour) + session, err := security.GrantOauthSession( + user, + client, + strings.Split(scope, " "), + []string{"Hydrogen.Passport", client.Alias}, + &expired, + lo.ToPtr(time.Now()), + c.IP(), + c.Get(fiber.HeaderUserAgent), + ) + + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } else { + return c.JSON(fiber.Map{ + "session": session, + "redirect_uri": redirect, + }) + } + case "token": + // OAuth Implicit Mode + expired := time.Now().Add(7 * 24 * time.Hour) + session, err := security.GrantOauthSession( + user, + client, + strings.Split(scope, " "), + []string{"Hydrogen.Passport", client.Alias}, + &expired, + lo.ToPtr(time.Now()), + c.IP(), + c.Get(fiber.HeaderUserAgent), + ) + + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } else if access, refresh, err := security.GetToken(session); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } else { + return c.JSON(fiber.Map{ + "access_token": access, + "refresh_token": refresh, + "redirect_uri": redirect, + "session": session, + }) + } + default: + return fiber.NewError(fiber.StatusBadRequest, "unsupported response type") + } +} diff --git a/pkg/server/startup.go b/pkg/server/startup.go index bef1af7..fe1ba63 100644 --- a/pkg/server/startup.go +++ b/pkg/server/startup.go @@ -31,6 +31,9 @@ func NewServer() { api.Post("/auth", doChallenge) api.Post("/auth/token", exchangeToken) api.Post("/auth/factors/:factorId", requestFactorToken) + + api.Get("/auth/oauth/connect", auth, preConnect) + api.Post("/auth/oauth/connect", auth, doConnect) } } diff --git a/view/src/index.tsx b/view/src/index.tsx index 5dee3c7..6d26901 100644 --- a/view/src/index.tsx +++ b/view/src/index.tsx @@ -21,6 +21,8 @@ render(() => ( import("./pages/dashboard.tsx"))} /> import("./pages/auth/login.tsx"))} /> import("./pages/auth/register.tsx"))} /> + import("./pages/auth/connect.tsx"))} /> + import("./pages/auth/callback.tsx"))} /> import("./pages/users/confirm.tsx"))} /> diff --git a/view/src/pages/auth/callback.tsx b/view/src/pages/auth/callback.tsx new file mode 100644 index 0000000..5caf411 --- /dev/null +++ b/view/src/pages/auth/callback.tsx @@ -0,0 +1,30 @@ +import { useSearchParams } from "@solidjs/router"; + +export default function DefaultCallbackPage() { + const [searchParams] = useSearchParams(); + + return ( +
+
+
+ + +
+

Authorization Code

+ {searchParams["code"]} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/view/src/pages/auth/connect.tsx b/view/src/pages/auth/connect.tsx new file mode 100644 index 0000000..d5ffabe --- /dev/null +++ b/view/src/pages/auth/connect.tsx @@ -0,0 +1,140 @@ +import { createSignal, Show } from "solid-js"; +import { useLocation, useSearchParams } from "@solidjs/router"; +import { getAtk, useUserinfo } from "../../stores/userinfo.tsx"; + +export default function OauthConnectPage() { + const [title, setTitle] = createSignal("Connect Third-party"); + const [subtitle, setSubtitle] = createSignal("Via your Goatpass account"); + + const [error, setError] = createSignal(null); + const [status, setStatus] = createSignal("Handshaking..."); + const [loading, setLoading] = createSignal(true); + + const [client, setClient] = createSignal(null); + + const [searchParams] = useSearchParams(); + + const userinfo = useUserinfo(); + const location = useLocation(); + + async function preConnect() { + const res = await fetch(`/api/auth/oauth/connect${location.search}`, { + headers: { "Authorization": `Bearer ${getAtk()}` } + }); + + if (res.status !== 200) { + setError(await res.text()); + } else { + const data = await res.json(); + + if (data["session"]) { + setStatus("Redirecting..."); + redirect(data["session"]); + } else { + setTitle(`Connect ${data["client"].name}`); + setSubtitle(`Via ${userinfo?.displayName}`); + setClient(data["client"]); + setLoading(false); + } + } + } + + function decline() { + if (window.history.length > 0) { + window.history.back(); + } else { + window.close(); + } + } + + async function approve() { + setLoading(true); + setStatus("Approving..."); + + const res = await fetch("/api/auth/oauth/connect?" + new URLSearchParams({ + client_id: searchParams["client_id"] as string, + redirect_uri: encodeURIComponent(searchParams["redirect_uri"] as string), + response_type: "code", + scope: searchParams["scope"] as string + }), { + method: "POST", + headers: { "Authorization": `Bearer ${getAtk()}` } + }); + + if (res.status !== 200) { + setError(await res.text()); + setLoading(false); + } else { + const data = await res.json(); + setStatus("Redirecting..."); + setTimeout(() => redirect(data["session"]), 1850); + } + } + + function redirect(session: any) { + const url = `${searchParams["redirect_uri"]}?code=${session["grant_token"]}&state=${searchParams["state"]}`; + window.open(url, "_self"); + } + + preConnect(); + + return ( +
+
+
+ + + +
+ +
+
+ + +
+
+
+ +
+ {status()} +
+
+
+ + +
+

About who you connecting to

+

{client().description}

+
+
+

Make sure you trust them

+

You may share your personal information after connect them. Learn about their privacy policy and user + agreement to keep your personal information in safe.

+
+
+

After approve this request

+

+ You will be redirect to{" "} + {searchParams["redirect_uri"]} +

+
+
+ + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/view/src/pages/auth/login.tsx b/view/src/pages/auth/login.tsx index 753f6a8..ed0c995 100644 --- a/view/src/pages/auth/login.tsx +++ b/view/src/pages/auth/login.tsx @@ -108,7 +108,7 @@ export default function LoginPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: tk, - grant_type: "authorization_code" + grant_type: "grant_token" }) }); if (res.status !== 200) { diff --git a/view/src/pages/dashboard.tsx b/view/src/pages/dashboard.tsx index 8f692b2..56bdd56 100644 --- a/view/src/pages/dashboard.tsx +++ b/view/src/pages/dashboard.tsx @@ -5,7 +5,7 @@ export default function DashboardPage() { const userinfo = useUserinfo(); return ( -
+

Welcome, {userinfo?.displayName}

What's a nice day!