An entire complete sign in user flow

This commit is contained in:
2024-04-21 01:33:42 +08:00
parent e79441dbc5
commit ee6e7324b2
21 changed files with 467 additions and 52 deletions

View File

@@ -4,9 +4,14 @@
"username": "Username",
"nickname": "Nickname",
"password": "Password",
"unknown": "Unknown",
"magicToken": "Magic Token",
"signinTitle": "Sign In",
"signinCaption": "Sign in to Solarpass to explore entire Solar Network. Explore posts, discover communities, talk with your best friends. All these things in the Solar Network!",
"signinRequired": "You need to sign in before do that.",
"signupTitle": "Sign Up",
"signupCaption": "Sign up to create an account on Solarpass, then you can explore the entire Solar Network! Enjoy the next-generation Internet Ecosystem!"
"signupCaption": "Sign up to create an account on Solarpass, then you can explore the entire Solar Network! Enjoy the next-generation Internet Ecosystem!",
"mfaTitle": "Multi Factor Authenticate",
"mfaCaption": "We need use one more way to verify it is you.",
"mfaFactorEmail": "OTP through your email"
}

View File

@@ -4,9 +4,14 @@
"username": "用户名",
"nickname": "昵称",
"password": "密码",
"unknown": "未知",
"magicToken": "魔法令牌",
"signinTitle": "登陆",
"signinCaption": "登陆 Solarpass 以探索整个 Solar Network浏览帖子、探索社区、和你的好朋友聊八卦一切尽在 Solar Network!",
"signinRequired": "你需要在那之前登陆",
"signupTitle": "注册",
"signupCaption": "注册以在 Solarpass 创建一个账号,之后你就可以探索整个 Solar Network享受下一代互联网生态系统"
"signupCaption": "注册以在 Solarpass 创建一个账号,之后你就可以探索整个 Solar Network享受下一代互联网生态系统",
"mfaTitle": "多因素验证",
"mfaCaption": "我们需要另一个方法来确认你是你。",
"mfaFactorEmail": "电子邮寄一次性验证码"
}

View File

@@ -130,7 +130,7 @@ func NewServer() {
URL: "/favicon.png",
}))
ui.MapUserInterface(A)
ui.MapUserInterface(A, authFunc)
}
func Listen() {

View File

@@ -0,0 +1,7 @@
package ui
import "github.com/gofiber/fiber/v2"
func selfUserinfoPage(c *fiber.Ctx) error {
return c.Render("views/users/me/index", fiber.Map{})
}

View File

@@ -1,13 +1,41 @@
package ui
import "github.com/gofiber/fiber/v2"
import (
"fmt"
"git.solsynth.dev/hydrogen/passport/pkg/services"
"github.com/gofiber/fiber/v2"
)
func MapUserInterface(A *fiber.App, authFunc func(c *fiber.Ctx, overrides ...string) error) {
authCheckWare := func(c *fiber.Ctx) error {
var token string
if cookie := c.Cookies(services.CookieAccessKey); len(cookie) > 0 {
token = cookie
}
fmt.Println(token)
c.Locals("token", token)
if err := authFunc(c); err != nil {
fmt.Println(err)
uri := c.Request().URI().FullURI()
return c.Redirect(fmt.Sprintf("/sign-in?redirect_uri=%s", string(uri)))
} else {
return c.Next()
}
}
func MapUserInterface(A *fiber.App) {
pages := A.Group("/").Name("Pages")
pages.Get("/sign-up", signupPage)
pages.Get("/sign-in", signinPage)
pages.Get("/mfa", mfaRequestPage)
pages.Get("/mfa/apply", mfaApplyPage)
pages.Post("/sign-up", signupAction)
pages.Post("/sign-in", signinAction)
pages.Post("/mfa", mfaRequestAction)
pages.Post("/mfa/apply", mfaApplyAction)
pages.Get("/users/me", authCheckWare, selfUserinfoPage)
}

194
pkg/server/ui/mfa.go Normal file
View File

@@ -0,0 +1,194 @@
package ui
import (
"fmt"
"git.solsynth.dev/hydrogen/passport/pkg/models"
"git.solsynth.dev/hydrogen/passport/pkg/services"
"git.solsynth.dev/hydrogen/passport/pkg/utils"
"github.com/gofiber/fiber/v2"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/samber/lo"
"github.com/sujit-baniya/flash"
)
func mfaRequestPage(c *fiber.Ctx) error {
ticketId := c.QueryInt("ticket", 0)
ticket, err := services.GetTicket(uint(ticketId))
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": "you must provide ticket id to perform multi-factor authenticate",
}).Redirect("/sign-in")
}
user, err := services.GetAccount(ticket.AccountID)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": "ticket related user just weirdly disappear",
}).Redirect("/sign-in")
}
factors, err := services.ListUserFactor(user.ID)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("unable to get your factors: %v", err.Error()),
}).Redirect("/sign-in")
}
factors = lo.Filter(factors, func(item models.AuthFactor, index int) bool {
return item.Type != models.PasswordAuthFactor
})
localizer := c.Locals("localizer").(*i18n.Localizer)
next, _ := localizer.LocalizeMessage(&i18n.Message{ID: "next"})
title, _ := localizer.LocalizeMessage(&i18n.Message{ID: "mfaTitle"})
caption, _ := localizer.LocalizeMessage(&i18n.Message{ID: "mfaCaption"})
return c.Render("views/mfa", fiber.Map{
"info": flash.Get(c)["message"],
"redirect_uri": flash.Get(c)["redirect_uri"],
"ticket_id": ticket.ID,
"factors": lo.Map(factors, func(item models.AuthFactor, index int) fiber.Map {
return fiber.Map{
"name": services.GetFactorName(item.Type, localizer),
"id": item.ID,
}
}),
"i18n": fiber.Map{
"next": next,
"title": title,
"caption": caption,
},
}, "views/layouts/auth")
}
func mfaRequestAction(c *fiber.Ctx) error {
var data struct {
TicketID uint `form:"ticket_id" validate:"required"`
FactorID uint `form:"factor_id" validate:"required"`
}
redirectBackUri := "/sign-in"
err := utils.BindAndValidate(c, &data)
if data.TicketID > 0 {
redirectBackUri = fmt.Sprintf("/mfa?ticket=%d", data.TicketID)
}
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": err.Error(),
}).Redirect(redirectBackUri)
}
factor, err := services.GetFactor(data.FactorID)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("factor was not found: %v", err.Error()),
}).Redirect(redirectBackUri)
}
_, err = services.GetFactorCode(factor)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("unable to get factor code: %v", err.Error()),
}).Redirect(redirectBackUri)
}
return flash.WithData(c, fiber.Map{
"redirect_uri": utils.GetRedirectUri(c),
}).Redirect(fmt.Sprintf("/mfa/apply?ticket=%d&factor=%d", data.TicketID, factor.ID))
}
func mfaApplyPage(c *fiber.Ctx) error {
ticketId := c.QueryInt("ticket", 0)
factorId := c.QueryInt("factor", 0)
ticket, err := services.GetTicket(uint(ticketId))
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("unable to find your ticket: %v", err.Error()),
}).Redirect("/sign-in")
}
factor, err := services.GetFactor(uint(factorId))
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("unable to find your factors: %v", err.Error()),
}).Redirect("/sign-in")
}
localizer := c.Locals("localizer").(*i18n.Localizer)
next, _ := localizer.LocalizeMessage(&i18n.Message{ID: "next"})
password, _ := localizer.LocalizeMessage(&i18n.Message{ID: "password"})
title, _ := localizer.LocalizeMessage(&i18n.Message{ID: "mfaTitle"})
caption, _ := localizer.LocalizeMessage(&i18n.Message{ID: "mfaCaption"})
return c.Render("views/mfa-apply", fiber.Map{
"info": flash.Get(c)["message"],
"label": services.GetFactorName(factor.Type, localizer),
"ticket_id": ticket.ID,
"factor_id": factor.ID,
"i18n": fiber.Map{
"next": next,
"password": password,
"title": title,
"caption": caption,
},
}, "views/layouts/auth")
}
func mfaApplyAction(c *fiber.Ctx) error {
var data struct {
TicketID uint `form:"ticket_id" validate:"required"`
FactorID uint `form:"factor_id" validate:"required"`
Code string `form:"code" validate:"required"`
}
redirectBackUri := "/sign-in"
err := utils.BindAndValidate(c, &data)
if data.TicketID > 0 {
redirectBackUri = fmt.Sprintf("/mfa/apply?ticket=%d&factor=%d", data.TicketID, data.FactorID)
}
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": err.Error(),
}).Redirect(redirectBackUri)
}
ticket, err := services.GetTicket(data.TicketID)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("unable to find your ticket: %v", err.Error()),
}).Redirect("/sign-in")
}
factor, err := services.GetFactor(data.FactorID)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("factor was not found: %v", err.Error()),
}).Redirect(redirectBackUri)
}
ticket, err = services.ActiveTicketWithMFA(ticket, factor, data.Code)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("invalid multi-factor authenticate code: %v", err.Error()),
}).Redirect(redirectBackUri)
} else if ticket.IsAvailable() != nil {
return flash.WithInfo(c, fiber.Map{
"message": "ticket weirdly still unavailable after multi-factor authenticate",
}).Redirect("/sign-in")
}
access, refresh, err := services.ExchangeToken(*ticket.GrantToken)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": fmt.Sprintf("failed to exchange token: %v", err.Error()),
}).Redirect("/sign-in")
} else {
services.SetJwtCookieSet(c, access, refresh)
}
return c.Redirect(lo.FromPtr(utils.GetRedirectUri(c, "/users/me")))
}

View File

@@ -6,6 +6,7 @@ import (
"git.solsynth.dev/hydrogen/passport/pkg/utils"
"github.com/gofiber/fiber/v2"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/samber/lo"
"github.com/sujit-baniya/flash"
)
@@ -18,9 +19,17 @@ func signinPage(c *fiber.Ctx) error {
signup, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signupTitle"})
title, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signinTitle"})
caption, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signinCaption"})
requiredNotify, _ := localizer.LocalizeMessage(&i18n.Message{ID: "signinRequired"})
var info any
if flash.Get(c)["message"] != nil {
info = flash.Get(c)["message"]
} else {
info = requiredNotify
}
return c.Render("views/signin", fiber.Map{
"info": flash.Get(c)["message"],
"info": info,
"i18n": fiber.Map{
"next": next,
"username": username,
@@ -66,12 +75,19 @@ func signinAction(c *fiber.Ctx) error {
}
if ticket.IsAvailable() != nil {
return flash.WithData(c, fiber.Map{
"redirect_uri": utils.GetRedirectUri(c),
}).Redirect(fmt.Sprintf("/mfa?ticket=%d", ticket.ID))
}
access, refresh, err := services.ExchangeToken(*ticket.GrantToken)
if err != nil {
return flash.WithInfo(c, fiber.Map{
"message": "multi factor authenticate required",
"message": fmt.Sprintf("failed to exchange token: %v", err.Error()),
}).Redirect("/sign-in")
} else {
return flash.WithInfo(c, fiber.Map{
"message": "done",
}).Redirect("/sign-in")
services.SetJwtCookieSet(c, access, refresh)
}
return c.Redirect(lo.FromPtr(utils.GetRedirectUri(c, "/users/me")))
}

View File

@@ -8,6 +8,7 @@ import (
"git.solsynth.dev/hydrogen/passport/pkg/utils"
"github.com/gofiber/fiber/v2"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/samber/lo"
"github.com/spf13/viper"
"github.com/sujit-baniya/flash"
)
@@ -81,6 +82,6 @@ func signupAction(c *fiber.Ctx) error {
} else {
return flash.WithInfo(c, fiber.Map{
"message": "account has been created. now you can sign in!",
}).Redirect("/sign-in")
}).Redirect(lo.FromPtr(utils.GetRedirectUri(c, "/sign-in")))
}
}

View File

@@ -25,7 +25,7 @@ Thank you for your cooperation in helping us maintain the security of your accou
Best regards,
%s`
func GetPasswordFactor(userId uint) (models.AuthFactor, error) {
func GetPasswordTypeFactor(userId uint) (models.AuthFactor, error) {
var factor models.AuthFactor
err := database.C.Where(models.AuthFactor{
Type: models.PasswordAuthFactor,
@@ -53,6 +53,15 @@ func ListUserFactor(userId uint) ([]models.AuthFactor, error) {
return factors, err
}
func CountUserFactor(userId uint) int64 {
var count int64
database.C.Where(models.AuthFactor{
AccountID: userId,
}).Model(&models.AuthFactor{}).Count(&count)
return count
}
func GetFactorCode(factor models.AuthFactor) (bool, error) {
switch factor.Type {
case models.EmailPasswordFactor:

18
pkg/services/mfa.go Normal file
View File

@@ -0,0 +1,18 @@
package services
import (
"git.solsynth.dev/hydrogen/passport/pkg/models"
"github.com/nicksnyder/go-i18n/v2/i18n"
)
func GetFactorName(w models.AuthFactorType, localizer *i18n.Localizer) string {
unknown, _ := localizer.LocalizeMessage(&i18n.Message{ID: "unknown"})
mfaEmail, _ := localizer.LocalizeMessage(&i18n.Message{ID: "mfaFactorEmail"})
switch w {
case models.EmailPasswordFactor:
return mfaEmail
default:
return unknown
}
}

View File

@@ -27,18 +27,23 @@ func DetectRisk(user models.Account, ip, ua string) bool {
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 {
if err := database.C.
Where("account_id = ? AND expired_at < ? AND available_at IS NULL", time.Now(), user.ID).
First(&ticket).Error; err == nil {
return ticket, nil
}
requireMFA := DetectRisk(user, ip, ua)
if count := CountUserFactor(user.ID); count <= 1 {
requireMFA = false
}
ticket = models.AuthTicket{
Claims: []string{"*"},
Audiences: []string{"passport"},
IpAddress: ip,
UserAgent: ua,
RequireMFA: DetectRisk(user, ip, ua),
RequireMFA: requireMFA,
RequireAuthenticate: true,
ExpiredAt: lo.ToPtr(time.Now().Add(2 * time.Hour)),
AvailableAt: nil,
@@ -85,16 +90,19 @@ func ActiveTicketWithPassword(ticket models.AuthTicket, password string) (models
return ticket, nil
}
if factor, err := GetPasswordFactor(ticket.AccountID); err != nil {
if factor, err := GetPasswordTypeFactor(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())
ticket.RequireAuthenticate = false
if !ticket.RequireAuthenticate && !ticket.RequireMFA {
ticket.AvailableAt = lo.ToPtr(time.Now())
ticket.GrantToken = lo.ToPtr(uuid.NewString())
ticket.AccessToken = lo.ToPtr(uuid.NewString())
ticket.RefreshToken = lo.ToPtr(uuid.NewString())
}
if err := database.C.Save(&ticket).Error; err != nil {
@@ -119,6 +127,9 @@ func ActiveTicketWithMFA(ticket models.AuthTicket, factor models.AuthFactor, cod
if !ticket.RequireAuthenticate && !ticket.RequireMFA {
ticket.AvailableAt = lo.ToPtr(time.Now())
ticket.GrantToken = lo.ToPtr(uuid.NewString())
ticket.AccessToken = lo.ToPtr(uuid.NewString())
ticket.RefreshToken = lo.ToPtr(uuid.NewString())
}
if err := database.C.Save(&ticket).Error; err != nil {
@@ -128,10 +139,10 @@ func ActiveTicketWithMFA(ticket models.AuthTicket, factor models.AuthFactor, cod
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
func RegenSession(ticket models.AuthTicket) (models.AuthTicket, error) {
ticket.GrantToken = lo.ToPtr(uuid.NewString())
ticket.AccessToken = lo.ToPtr(uuid.NewString())
ticket.RefreshToken = lo.ToPtr(uuid.NewString())
err := database.C.Save(&ticket).Error
return ticket, err
}

View File

@@ -19,8 +19,8 @@ func GetToken(ticket models.AuthTicket) (string, string, error) {
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
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(ticket.AccountID))

View File

@@ -3,6 +3,8 @@ package utils
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/samber/lo"
"github.com/sujit-baniya/flash"
)
var validation = validator.New(validator.WithRequiredStructEnabled())
@@ -16,3 +18,15 @@ func BindAndValidate(c *fiber.Ctx, out any) error {
return nil
}
func GetRedirectUri(c *fiber.Ctx, fallback ...string) *string {
if len(c.Query("redirect_uri")) > 0 {
return lo.ToPtr(c.Query("redirect_uri"))
} else if val, ok := flash.Get(c)["redirect_uri"].(*string); ok {
return val
} else if len(fallback) > 0 {
return &fallback[0]
} else {
return nil
}
}

View File

@@ -55,8 +55,6 @@
}
.alert {
width: 100%;
max-width: 800px;
padding: 16px;
border-radius: 16px;
background-color: var(--md-sys-color-secondary-container);

View File

@@ -0,0 +1,47 @@
<div class="left-part">
<img class="logo" alt="Logo" src="/favicon.png" width="64" height="64"/>
<h1 class="title">{{.i18n.title}}</h1>
<p class="caption">{{.i18n.caption}}</p>
</div>
<div class="right-part">
<div class="responsive-title-gap"></div>
<form class="action-form" action="/mfa/apply" method="POST">
<label>
<input name="ticket_id" value="{{.ticket_id}}" hidden>
</label>
<label>
<input name="factor_id" value="{{.factor_id}}" hidden>
</label>
<div class="factor-label">{{.label}}</div>
<md-outlined-text-field
class="block-field"
name="code"
type="password"
autocomplete="off"
label={{.i18n.password}}
>
</md-outlined-text-field>
<div class="action-form-buttons">
<md-filled-button type="submit">{{.i18n.next}}</md-filled-button>
</div>
</form>
</div>
<style>
.factor-label {
font-size: 14px;
text-align: left;
}
@media (min-width: 768px) {
.factor-label {
text-align: center;
}
}
</style>

61
pkg/views/mfa.gohtml Normal file
View File

@@ -0,0 +1,61 @@
<div class="left-part">
<img class="logo" alt="Logo" src="/favicon.png" width="64" height="64"/>
<h1 class="title">{{.i18n.title}}</h1>
<p class="caption">{{.i18n.caption}}</p>
</div>
<div class="right-part">
<div class="responsive-title-gap"></div>
<form class="action-form" action="/mfa" method="POST">
<label>
<input name="ticket_id" value="{{.ticket_id}}" hidden>
</label>
{{if ne .redirect_uri nil}}
<label>
<input name="redirect_uri" value="{{.redirect_uri}}" hidden>
</label>
{{end}}
<div class="block-field factor-list" role="radiogroup">
{{range $_, $element := .factors}}
<div class="factor-label">
<md-radio
aria-label="{{$element.name}}"
id="factor-{{$element.id}}"
value="{{$element.id}}"
touch-target="wrapper"
name="factor_id"
>
</md-radio>
<label for="factor-{{$element.id}}">{{$element.name}}</label>
</div>
{{end}}
</div>
<div class="action-form-buttons">
<md-filled-button type="submit">{{.i18n.next}}</md-filled-button>
</div>
</form>
</div>
<style>
.factor-list {
display: flex;
flex-direction: column;
}
.factor-label {
display: flex;
align-items: center;
}
.factor-label label {
display: inline-flex;
place-items: center;
gap: 8px;
font-family: Roboto, system-ui;
color: var(--md-sys-color-on-background);
}
</style>

View File

@@ -1,5 +1,5 @@
<div class="left-part">
<img class="logo" src="/favicon.png" width="64" height="64"/>
<img class="logo" alt="Logo" src="/favicon.png" width="64" height="64"/>
<h1 class="title">{{.i18n.title}}</h1>
<p class="caption">{{.i18n.caption}}</p>

View File

@@ -1,5 +1,5 @@
<div class="left-part">
<img class="logo" src="/favicon.png" width="64" height="64"/>
<img class="logo" alt="Logo" src="/favicon.png" width="64" height="64"/>
<h1 class="title">{{.i18n.title}}</h1>
<p class="caption">{{.i18n.caption}}</p>

View File

@@ -0,0 +1,3 @@
<h1>
You are accepted!
</h1>