├── .gitignore
├── main.go
├── .idea
├── .gitignore
├── vcs.xml
├── copilot.data.migration.agent.xml
├── copilot.data.migration.ask.xml
├── copilot.data.migration.edit.xml
├── copilot.data.migration.ask2agent.xml
├── modules.xml
├── dataSources.xml
└── openid.iml
├── library
├── mailutil
│ ├── mail.go
│ ├── struct.go
│ ├── smtp.go
│ ├── aliyun.go
│ └── beacon.go
├── codeutil
│ ├── struct.go
│ └── code.go
├── apiutil
│ ├── struct.go
│ └── api.go
├── userutil
│ ├── identity.go
│ ├── struct.go
│ ├── user.go
│ └── jwt.go
├── mq
│ ├── struct.go
│ └── mq.go
├── toolutil
│ ├── hash.go
│ ├── re.go
│ └── rand.go
├── passkey
│ ├── struct.go
│ ├── session.go
│ ├── user.go
│ ├── storage.go
│ └── passkey.go
└── apputil
│ ├── struct.go
│ └── apputil.go
├── AGENTS.md
├── app
├── dto
│ ├── login.go
│ ├── forget.go
│ ├── register.go
│ ├── common.go
│ ├── user.go
│ ├── app.go
│ └── passkey.go
├── controller
│ ├── ping.go
│ ├── login.go
│ ├── forget.go
│ ├── register.go
│ ├── app.go
│ ├── user.go
│ └── passkey.go
├── model
│ ├── unique_id.go
│ ├── open_id.go
│ ├── app.go
│ ├── account.go
│ └── passkey.go
└── middleware
│ ├── cors.go
│ ├── permission.go
│ └── user_app.go
├── api
└── version_one
│ ├── helper
│ ├── struct.go
│ ├── helper.go
│ └── generate.go
│ ├── login.go
│ ├── app.go
│ ├── info.go
│ └── code.go
├── .github
└── workflows
│ └── test.yaml
├── process
├── queueutil
│ ├── queueutil.go
│ └── mail.go
├── webutil
│ ├── webutil.go
│ └── route.go
├── redisutil
│ └── redisutil.go
└── dbutil
│ └── dbutil.go
├── core
└── core.go
├── README.md
├── config
├── config.go
└── struct.go
├── config.tpl
├── go.mod
├── docs
├── passkey-frontend-todo.md
└── passkey-api.md
├── lang-go-Standards.md
├── LICENSE
├── go.sum
├── lang-multi-Standards.md
└── openapi.yaml
/.gitignore:
--------------------------------------------------------------------------------
1 | tmp/
2 | build
3 | config.yaml
4 | .DS_Store
5 | main
6 | openid
7 | .VSCodeCounter
8 | *.key
9 | *.pem
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "github.com/soxft/openid-go/core"
4 |
5 | func main() {
6 | core.Init()
7 | }
8 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # 默认忽略的文件
2 | /shelf/
3 | /workspace.xml
4 | # 基于编辑器的 HTTP 客户端请求
5 | /httpRequests/
6 | # Datasource local storage ignored files
7 | /dataSources/
8 | /dataSources.local.xml
9 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/copilot.data.migration.agent.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/copilot.data.migration.ask.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/copilot.data.migration.edit.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/copilot.data.migration.ask2agent.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/library/mailutil/mail.go:
--------------------------------------------------------------------------------
1 | package mailutil
2 |
3 | func Send(mail Mail, platform MailPlatform) error {
4 | switch platform {
5 | case MailPlatformAliyun:
6 | return SendByAliyun(mail)
7 | default:
8 | return SendByAliyun(mail)
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # LLM 代理开发指南
2 |
3 | 本文档为使用 LLM 代码代理进行开发提供了核心指南和资源链接。
4 |
5 | ## 开发规范
6 |
7 | 所有由 LLM 代理生成的代码都必须严格遵守以下语言开发规范:
8 |
9 | - [Go 语言开发规范](./lang-go-Standards.md)
10 | - [多语言开发规范](./lang-multi-Standards.md)
11 |
12 | ---
13 | *请在添加新的语言规范后,及时更新此文件。*
--------------------------------------------------------------------------------
/library/mailutil/struct.go:
--------------------------------------------------------------------------------
1 | package mailutil
2 |
3 | type Mail struct {
4 | Subject string
5 | Content string
6 | ToAddress string
7 | Typ string // 邮件类型
8 | }
9 |
10 | type MailPlatform string
11 |
12 | const (
13 | MailPlatformAliyun MailPlatform = "aliyun"
14 | )
15 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/app/dto/login.go:
--------------------------------------------------------------------------------
1 | package dto
2 |
3 | // LoginRequest 登录请求
4 | type LoginRequest struct {
5 | Username string `json:"username" binding:"required"`
6 | Password string `json:"password" binding:"required"`
7 | }
8 |
9 | // LoginResponse 登录响应
10 | type LoginResponse struct {
11 | Token string `json:"token"`
12 | }
--------------------------------------------------------------------------------
/app/controller/ping.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/soxft/openid-go/library/apiutil"
6 | "time"
7 | )
8 |
9 | func Ping(c *gin.Context) {
10 | api := apiutil.New(c)
11 |
12 | api.SuccessWithData("pong", gin.H{
13 | "timestamp": time.Now().Unix(),
14 | })
15 | }
16 |
--------------------------------------------------------------------------------
/app/model/unique_id.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type UniqueId struct {
4 | ID int `gorm:"autoIncrement;primaryKey"`
5 | UserId int `gorm:"index"`
6 | DevUserId int `gorm:"index"`
7 | UniqueId string `gorm:"type:varchar(128);uniqueIndex"`
8 | CreateAt int64 `gorm:"autoCreateTime"`
9 | }
10 |
11 | func (UniqueId) TableName() string {
12 | return "unique_id"
13 | }
14 |
--------------------------------------------------------------------------------
/app/model/open_id.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type OpenId struct {
4 | ID int `gorm:"autoIncrement;primaryKey"`
5 | UserId int `gorm:"index"`
6 | AppId string `gorm:"type:varchar(20);index"`
7 | OpenId string `gorm:"type:varchar(128);uniqueIndex"`
8 | CreateAt int64 `gorm:"autoCreateTime"`
9 | }
10 |
11 | func (OpenId) TableName() string {
12 | return "open_id"
13 | }
14 |
--------------------------------------------------------------------------------
/api/version_one/helper/struct.go:
--------------------------------------------------------------------------------
1 | package helper
2 |
3 | import "errors"
4 |
5 | type ApiErr = error
6 |
7 | var (
8 | ErrTokenNotExists = errors.New("token not exists")
9 |
10 | ErrOpenIdExists = errors.New("openId exists")
11 | ErrUniqueIdExists = errors.New("uniqueId exists")
12 | )
13 |
14 | type UserIdsStruct struct {
15 | OpenId string `json:"openId"`
16 | UniqueId string `json:"uniqueId"`
17 | }
18 |
--------------------------------------------------------------------------------
/library/codeutil/struct.go:
--------------------------------------------------------------------------------
1 | package codeutil
2 |
3 | import (
4 | "context"
5 | "time"
6 | )
7 |
8 | type Coder interface {
9 | Create(length int) string
10 | Save(topic string, email string, code string, timeout time.Duration) error
11 | Check(topic string, email string, code string) (bool, error)
12 | Consume(topic string, email string)
13 | }
14 |
15 | type VerifyCode struct {
16 | ctx context.Context
17 | }
18 |
--------------------------------------------------------------------------------
/app/model/app.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type App struct {
4 | ID int `gorm:"autoIncrement;primaryKey"`
5 | UserId int `gorm:"index"`
6 | AppId string `gorm:"type:varchar(20);uniqueIndex"`
7 | AppName string `gorm:"type:varchar(128)"`
8 | AppSecret string `gorm:"type:varchar(100);uniqueIndex"`
9 | AppGateway string `gorm:"type:varchar(200)"`
10 | CreateAt int64 `gorm:"autoCreateTime"`
11 | }
12 |
--------------------------------------------------------------------------------
/library/apiutil/struct.go:
--------------------------------------------------------------------------------
1 | package apiutil
2 |
3 | import "github.com/gin-gonic/gin"
4 |
5 | type Apier interface {
6 | Out(success bool, msg string, data interface{})
7 | Success(msg string, data interface{})
8 | Fail(msg string)
9 | FailWithMsg(msg string, data interface{})
10 | Abort(httpCode int, msg string, errorCode int)
11 | Abort401(msg string, errCode int)
12 | }
13 |
14 | type Api struct {
15 | Ctx *gin.Context
16 | }
17 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: Build Test
2 |
3 | on:
4 | push:
5 | branches: [ "main", "dev" ]
6 | pull_request:
7 | branches: [ "main", "dev" ]
8 |
9 | jobs:
10 |
11 | build:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v3
15 |
16 | - name: Set up Go
17 | uses: actions/setup-go@v3
18 | with:
19 | go-version: 1.18
20 |
21 | - name: Build
22 | run: go build -v ./...
--------------------------------------------------------------------------------
/app/dto/forget.go:
--------------------------------------------------------------------------------
1 | package dto
2 |
3 | // ForgetPasswordCodeRequest 忘记密码发送验证码请求
4 | type ForgetPasswordCodeRequest struct {
5 | Email string `json:"email" binding:"required,email"`
6 | }
7 |
8 | // ForgetPasswordUpdateRequest 忘记密码更新请求
9 | type ForgetPasswordUpdateRequest struct {
10 | Email string `json:"email" binding:"required,email"`
11 | Code string `json:"code" binding:"required"`
12 | Password string `json:"password" binding:"required,min=8,max=64"`
13 | }
--------------------------------------------------------------------------------
/library/userutil/identity.go:
--------------------------------------------------------------------------------
1 | package userutil
2 |
3 | import "golang.org/x/crypto/bcrypt"
4 |
5 | // GeneratePwd hash password
6 | func GeneratePwd(password string) (string, error) {
7 | str, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
8 | return string(str), err
9 | }
10 |
11 | // CheckPwd check if password is correct
12 | func CheckPwd(password, hash string) error {
13 | return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
14 | }
15 |
--------------------------------------------------------------------------------
/app/model/account.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type Account struct {
4 | ID int `gorm:"autoIncrement;primaryKey"`
5 | Username string `gorm:"type:varchar(20);uniqueIndex;not null"`
6 | Password string `gorm:"type:varchar(128);not null"`
7 | Email string `gorm:"type:varchar(128);uniqueIndex"`
8 | RegTime int64 `gorm:"bigint(20)"`
9 | RegIp string `gorm:"type:varchar(128)"`
10 | LastTime int64 `gorm:"bigint(20)"`
11 | LastIp string `gorm:"type:varchar(128)"`
12 | }
13 |
--------------------------------------------------------------------------------
/app/dto/register.go:
--------------------------------------------------------------------------------
1 | package dto
2 |
3 | // RegisterCodeRequest 发送注册验证码请求
4 | type RegisterCodeRequest struct {
5 | Email string `json:"email" binding:"required,email"`
6 | }
7 |
8 | // RegisterSubmitRequest 提交注册请求
9 | type RegisterSubmitRequest struct {
10 | Email string `json:"email" binding:"required,email"`
11 | Code string `json:"code" binding:"required"`
12 | Username string `json:"username" binding:"required"`
13 | Password string `json:"password" binding:"required"`
14 | }
--------------------------------------------------------------------------------
/process/queueutil/queueutil.go:
--------------------------------------------------------------------------------
1 | package queueutil
2 |
3 | import (
4 | "context"
5 | "github.com/soxft/openid-go/library/mq"
6 | "github.com/soxft/openid-go/process/redisutil"
7 | "log"
8 | )
9 |
10 | var Q mq.MessageQueue
11 |
12 | // Init
13 | // @desc golang消息队列
14 | func Init() {
15 | log.Printf("[INFO] Queue initailizing...")
16 |
17 | // do nothing
18 | Q = mq.New(context.Background(), redisutil.RDB, 3)
19 |
20 | Q.Subscribe("mail", 2, Mail)
21 |
22 | log.Printf("[INFO] Queue initailize success")
23 | }
24 |
--------------------------------------------------------------------------------
/core/core.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/soxft/openid-go/process/dbutil"
7 | "github.com/soxft/openid-go/process/queueutil"
8 | "github.com/soxft/openid-go/process/redisutil"
9 | "github.com/soxft/openid-go/process/webutil"
10 | )
11 |
12 | func Init() {
13 | log.Printf("Server initailizing...")
14 |
15 | // init redis
16 | redisutil.Init()
17 |
18 | // init db
19 | dbutil.Init()
20 |
21 | // init queue
22 | queueutil.Init()
23 |
24 | // init web
25 | webutil.Init()
26 | }
27 |
--------------------------------------------------------------------------------
/library/mq/struct.go:
--------------------------------------------------------------------------------
1 | package mq
2 |
3 | import (
4 | "context"
5 | "github.com/redis/go-redis/v9"
6 | )
7 |
8 | type MessageQueue interface {
9 | Publish(topic string, msg string, delay int64) error
10 | Subscribe(topic string, processes int, handler func(msg string))
11 | }
12 |
13 | type QueueArgs struct {
14 | redis *redis.Client
15 | maxRetries int
16 | ctx context.Context
17 | }
18 |
19 | type MsgArgs struct {
20 | Msg string `json:"msg"`
21 | Retry int `json:"retry"`
22 | DelayAt int64 `json:"delay_at"`
23 | }
24 |
--------------------------------------------------------------------------------
/library/toolutil/hash.go:
--------------------------------------------------------------------------------
1 | package toolutil
2 |
3 | import (
4 | "crypto/md5"
5 | "crypto/sha1"
6 | "encoding/hex"
7 | )
8 |
9 | func Md5(str string) string {
10 | h := md5.New()
11 | h.Write([]byte(str))
12 | return hex.EncodeToString(h.Sum(nil))
13 | }
14 |
15 | func Sha1(str string) string {
16 | h := sha1.New()
17 | h.Write([]byte(str))
18 | return hex.EncodeToString(h.Sum(nil))
19 | }
20 |
21 | func Sha256(str, salt string) string {
22 | h := sha1.New()
23 | h.Write([]byte(str))
24 | h.Write([]byte(salt))
25 | return hex.EncodeToString(h.Sum(nil))
26 | }
27 |
--------------------------------------------------------------------------------
/.idea/dataSources.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | mysql.8
6 | true
7 | com.mysql.cj.jdbc.Driver
8 | jdbc:mysql://10.11.1.10:3306/openid
9 | $ProjectFileDir$
10 |
11 |
12 |
--------------------------------------------------------------------------------
/library/toolutil/re.go:
--------------------------------------------------------------------------------
1 | package toolutil
2 |
3 | import "regexp"
4 |
5 | func IsEmail(email string) bool {
6 | b, _ := regexp.MatchString("^([a-z0-9_.-]+)@([da-z.-]+).([a-z.]{2,6})$", email)
7 | return b
8 | }
9 |
10 | func IsUserName(id string) bool {
11 | b, _ := regexp.MatchString("^[0-9a-zA-Z]{5,30}$", id)
12 | return b
13 | }
14 |
15 | func IsPassword(password string) bool {
16 | if len(password) < 8 || len(password) > 64 {
17 | return false
18 | }
19 | return true
20 | }
21 |
22 | func IsDomain(domain string) bool {
23 | b, _ := regexp.MatchString("^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]\\.[a-zA-Z.]{2,6}$", domain)
24 | return b
25 | }
26 |
--------------------------------------------------------------------------------
/api/version_one/login.go:
--------------------------------------------------------------------------------
1 | package version_one
2 |
3 | import (
4 | "fmt"
5 | "github.com/gin-gonic/gin"
6 | "github.com/soxft/openid-go/config"
7 | "github.com/soxft/openid-go/library/apiutil"
8 | "net/url"
9 | )
10 |
11 | // Login
12 | // @description v1 登录
13 | // @route GET /v1/login
14 | func Login(c *gin.Context) {
15 | api := apiutil.New(c)
16 | appid := c.DefaultQuery("appid", "")
17 | redirectUri := c.DefaultQuery("redirect_uri", "")
18 | if appid == "" || redirectUri == "" {
19 | api.Fail("Invalid params")
20 | return
21 | }
22 |
23 | c.Redirect(302, fmt.Sprintf("%s/v1/%s?redirect_uri=%s", config.Server.FrontUrl, appid, url.QueryEscape(redirectUri)))
24 | }
25 |
--------------------------------------------------------------------------------
/app/middleware/cors.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/soxft/openid-go/config"
6 | )
7 |
8 | func Cors() gin.HandlerFunc {
9 | return func(c *gin.Context) {
10 | c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
11 | c.Writer.Header().Set("Server", config.Server.Name)
12 | if c.Request.Method == "OPTIONS" {
13 | c.Writer.Header().Set("Access-Control-Allow-Methods", c.Request.Header.Get("Access-Control-Request-Method"))
14 | c.Writer.Header().Set("Access-Control-Allow-Headers", c.Request.Header.Get("Access-Control-Request-Headers"))
15 | c.Writer.Header().Set("Access-Control-Max-Age", "86400")
16 | c.AbortWithStatus(204)
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/library/passkey/struct.go:
--------------------------------------------------------------------------------
1 | package passkey
2 |
3 | import (
4 | "github.com/go-webauthn/webauthn/protocol"
5 | )
6 |
7 | // RegistrationOptions 兼容旧代码的类型别名
8 | type RegistrationOptions = protocol.PublicKeyCredentialCreationOptions
9 |
10 | // LoginOptions 兼容旧代码的类型别名
11 | type LoginOptions = protocol.PublicKeyCredentialRequestOptions
12 |
13 | // Summary 用于对外输出的 Passkey 信息
14 | type Summary struct {
15 | ID int `json:"id"`
16 | Remark string `json:"remark"` // 备注
17 | CreatedAt int64 `json:"createdAt"`
18 | LastUsedAt int64 `json:"lastUsedAt"`
19 | CloneWarning bool `json:"cloneWarning"`
20 | SignCount uint32 `json:"signCount"`
21 | Transports []string `json:"transports"`
22 | }
23 |
--------------------------------------------------------------------------------
/process/webutil/webutil.go:
--------------------------------------------------------------------------------
1 | package webutil
2 |
3 | import (
4 | "log"
5 | "os"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/soxft/openid-go/config"
9 | )
10 |
11 | func Init() {
12 | log.Printf("[INFO] Web initailizing...")
13 |
14 | log.SetOutput(os.Stdout)
15 |
16 | // if debug
17 | if !config.Server.Debug {
18 | gin.SetMode(gin.ReleaseMode)
19 | }
20 |
21 | // init gin
22 | r := gin.New()
23 | initRoute(r)
24 |
25 | log.Printf("[INFO] Web initailizing success, running at %s ", config.Server.Addr)
26 | if err := r.Run(config.Server.Addr); err != nil {
27 | log.Panic(err)
28 | }
29 |
30 | // if err := r.RunTLS(config.Server.Addr, "server.pem", "server.key"); err != nil {
31 | // log.Panic(err)
32 | // }
33 | }
34 |
--------------------------------------------------------------------------------
/.idea/openid.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/library/apputil/struct.go:
--------------------------------------------------------------------------------
1 | package apputil
2 |
3 | import "errors"
4 |
5 | type AppBaseStruct struct {
6 | Id int `json:"id"`
7 | AppId string `json:"app_id"`
8 | AppName string `json:"app_name"`
9 | CreateAt int64 `json:"create_time"`
10 | }
11 |
12 | type AppFullInfoStruct struct {
13 | Id int `json:"id"`
14 | AppUserId int `json:"user_id"`
15 | AppId string `json:"app_id"`
16 | AppName string `json:"app_name"`
17 | AppSecret string `json:"app_secret"`
18 | AppGateway string `json:"app_gateway"`
19 | CreateAt int64 `json:"create_time"`
20 | }
21 |
22 | type AppErr = error
23 |
24 | var (
25 | ErrAppNotExist = errors.New("app not exist")
26 | ErrAppSecretNotMatch = errors.New("app secret not match")
27 | )
28 |
--------------------------------------------------------------------------------
/api/version_one/app.go:
--------------------------------------------------------------------------------
1 | package version_one
2 |
3 | import (
4 | "errors"
5 | "github.com/gin-gonic/gin"
6 | "github.com/soxft/openid-go/library/apiutil"
7 | "github.com/soxft/openid-go/library/apputil"
8 | "strings"
9 | )
10 |
11 | // AppInfo
12 | // @description 获取应用信息
13 | // @route GET /v1/app/info/:appId
14 | func AppInfo(c *gin.Context) {
15 | appId := c.Param("appid")
16 | api := apiutil.New(c)
17 |
18 | // get app info
19 | if appInfo, err := apputil.GetAppInfo(appId); err != nil {
20 | if errors.Is(err, apputil.ErrAppNotExist) {
21 | api.Fail("app not exist")
22 | return
23 | }
24 | api.Fail("system error")
25 | return
26 | } else {
27 | api.SuccessWithData("success", gin.H{
28 | "id": appInfo.Id,
29 | "name": appInfo.AppName,
30 | "gateway": strings.ReplaceAll(appInfo.AppGateway, ",", "\n"),
31 | })
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/controller/login.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/soxft/openid-go/app/dto"
6 | "github.com/soxft/openid-go/library/apiutil"
7 | "github.com/soxft/openid-go/library/userutil"
8 | )
9 |
10 | func Login(c *gin.Context) {
11 | var req dto.LoginRequest
12 | api := apiutil.New(c)
13 |
14 | if err := dto.BindJSON(c, &req); err != nil {
15 | api.Fail("请求参数错误")
16 | return
17 | }
18 |
19 | // check username and password
20 | if userId, err := userutil.CheckPassword(req.Username, req.Password); err != nil {
21 | api.Fail(err.Error())
22 | return
23 | } else {
24 | // get token
25 | if token, err := userutil.GenerateJwt(userId, c.ClientIP()); err != nil {
26 | api.Fail("system error")
27 | } else {
28 | api.SuccessWithData("登录成功", gin.H{
29 | "token": token,
30 | })
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/model/passkey.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type PassKey struct {
4 | ID int `gorm:"autoIncrement;primaryKey"`
5 | UserID int `gorm:"index;not null"`
6 | CredentialID string `gorm:"type:varchar(255);uniqueIndex;not null"`
7 | PublicKey string `gorm:"type:text;not null"`
8 | Attestation string `gorm:"type:varchar(32)"`
9 | AAGUID string `gorm:"type:varchar(64)"`
10 | SignCount uint32 `gorm:"type:int unsigned"`
11 | Transport string `gorm:"type:varchar(255)"`
12 | CloneWarning bool `gorm:"type:tinyint(1);default:0"`
13 | Remark string `gorm:"type:varchar(255);default:''"` // 备注字段
14 | CreatedAt int64 `gorm:"type:bigint;not null"`
15 | UpdatedAt int64 `gorm:"type:bigint;not null"`
16 | LastUsedAt int64 `gorm:"type:bigint;default:0"`
17 | }
18 |
19 | func (PassKey) TableName() string {
20 | return "pass_keys"
21 | }
22 |
--------------------------------------------------------------------------------
/process/queueutil/mail.go:
--------------------------------------------------------------------------------
1 | package queueutil
2 |
3 | import (
4 | "encoding/json"
5 | "github.com/soxft/openid-go/library/mailutil"
6 | "log"
7 | )
8 |
9 | // Mail
10 | // @description: 邮件发送相关
11 | func Mail(msg string) {
12 | var mailMsg mailutil.Mail
13 | if err := json.Unmarshal([]byte(msg), &mailMsg); err != nil {
14 | log.Panic(err)
15 | }
16 | if mailMsg.ToAddress == "" {
17 | log.Printf("[ERROR] Mail(%s) 空收件人", mailMsg.Typ)
18 | return
19 | }
20 | log.Printf("[INFO] Mail(%s) %s", mailMsg.Typ, mailMsg.ToAddress)
21 |
22 | // get mail platform
23 | var platform mailutil.MailPlatform
24 | switch mailMsg.Typ {
25 | case "register":
26 | platform = mailutil.MailPlatformAliyun
27 | default:
28 | platform = mailutil.MailPlatformAliyun
29 | }
30 | // send mail
31 | if err := mailutil.Send(mailMsg, platform); err != nil {
32 | log.Panic(err)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # openid-go
2 |
3 | The source code of https://9420.ltd backend
4 |
5 | # project structure
6 |
7 | ```
8 | ├── README.md
9 | ├── config
10 | │ ├── config.go # config
11 | │ └── config.yaml # config demo
12 | ├── main.go # entry
13 | ├── app
14 | │ ├── controller # controller
15 | │ ├── model # model
16 | │ ├── middleware # middleware
17 | ├── library
18 | │ ├── apiutil # api format
19 | │ ├── apputil # app related tools
20 | │ ├── codeutil # send verification code
21 | │ ├── mailutil # send mail
22 | │ ├── mq # redis based message queue
23 | │ ├── toolutil # tool like "hash" "randStr" "regex"
24 | │ ├── userutil # user management
25 | ├── process
26 | │ ├── dbutil # database related tools
27 | │ ├── queueutil # message queue
28 | │ ├── redisutil # redis
29 | │ ├── webutil # gin
30 | ```
31 |
32 | # copyright
33 |
34 | Copyright xcsoft 2023
--------------------------------------------------------------------------------
/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "gopkg.in/yaml.v2"
5 | "log"
6 | "os"
7 | )
8 |
9 | var (
10 | C *Config
11 | Server ServerConfig
12 | Redis RedisConfig
13 | Mysql MysqlConfig
14 | Smtp SmtpConfig
15 | Aliyun AliyunConfig
16 | Jwt JwtConfig
17 | Developer DeveloperConfig
18 | RedisPrefix string
19 | )
20 |
21 | func init() {
22 | data, err := os.ReadFile("config.yaml")
23 | if err != nil {
24 | log.Panicf("error when reading yaml: %v", err)
25 | }
26 | C = &Config{}
27 | if err := yaml.Unmarshal(data, C); err != nil {
28 | log.Panicf("error when unmarshal yaml: %v", err)
29 | }
30 |
31 | Server = C.ServerConfig
32 | Redis = C.RedisConfig
33 | Mysql = C.MysqlConfig
34 | Smtp = C.SmtpConfig
35 | Aliyun = C.AliyunConfig
36 | Jwt = C.JwtConfig
37 | Developer = C.DeveloperConfig
38 | RedisPrefix = C.RedisConfig.Prefix
39 | }
40 |
--------------------------------------------------------------------------------
/app/middleware/permission.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/soxft/openid-go/library/apiutil"
6 | "github.com/soxft/openid-go/library/userutil"
7 | )
8 |
9 | func AuthPermission() gin.HandlerFunc {
10 | return func(c *gin.Context) {
11 | api := apiutil.New(c)
12 |
13 | // check jwt token
14 | var token string
15 | if token = userutil.GetJwtFromAuth(c.GetHeader("Authorization")); token == "" {
16 | api.Abort401("Unauthorized", "middleware.permission.token_empty")
17 | return
18 | }
19 | if userInfo, err := userutil.CheckPermission(c, token); err != nil {
20 | api.Abort401("Unauthorized", "middleware.permission.token_invalid")
21 | return
22 | } else {
23 | c.Set("userId", userInfo.UserId)
24 | c.Set("username", userInfo.Username)
25 | c.Set("email", userInfo.Email)
26 | c.Set("lastTime", userInfo.LastTime)
27 | c.Set("token", token)
28 | }
29 | c.Next()
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/library/mailutil/smtp.go:
--------------------------------------------------------------------------------
1 | package mailutil
2 |
3 | import (
4 | "crypto/tls"
5 | "fmt"
6 | "github.com/soxft/openid-go/config"
7 | "gopkg.in/gomail.v2"
8 | "mime"
9 | )
10 |
11 | func sendBySmtp(mail Mail) error {
12 | m := gomail.NewMessage()
13 |
14 | _smtp := config.Smtp
15 |
16 | senderNameUtf8 := mime.QEncoding.Encode("utf-8", config.Server.Title)
17 | m.SetHeader("From", fmt.Sprintf("\"%s\" <%s>", senderNameUtf8, _smtp.User)) // 发件人
18 | m.SetHeader("To", mail.ToAddress) // 收件人
19 | m.SetHeader("Subject", mail.Subject) // 邮件主题
20 |
21 | m.SetBody("text/html; charset=UTF-8", mail.Content)
22 |
23 | d := gomail.NewDialer(
24 | _smtp.Host,
25 | _smtp.Port,
26 | _smtp.User,
27 | _smtp.Pwd,
28 | )
29 | if !config.Smtp.Secure {
30 | d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
31 | }
32 |
33 | if err := d.DialAndSend(m); err != nil {
34 | return err
35 | }
36 | return nil
37 | }
38 |
--------------------------------------------------------------------------------
/process/redisutil/redisutil.go:
--------------------------------------------------------------------------------
1 | package redisutil
2 |
3 | import (
4 | "context"
5 | "github.com/redis/go-redis/v9"
6 | "github.com/soxft/openid-go/config"
7 | "log"
8 | "time"
9 | )
10 |
11 | var RDB *redis.Client
12 |
13 | func Init() {
14 | log.Printf("[INFO] Redis trying connect to tcp://%s/%d", config.Redis.Addr, config.Redis.Db)
15 |
16 | r := config.Redis
17 |
18 | rdb := redis.NewClient(&redis.Options{
19 | Addr: r.Addr,
20 | Password: r.Pwd, // no password set
21 | DB: r.Db, // use default DB
22 | MinIdleConns: r.MinIdle,
23 | MaxIdleConns: r.MaxIdle,
24 | MaxRetries: r.MaxRetries,
25 | ConnMaxLifetime: 5 * time.Minute,
26 | MaxActiveConns: r.MaxActive,
27 | })
28 |
29 | RDB = rdb
30 |
31 | // test redis
32 | pong, err := rdb.Ping(context.Background()).Result()
33 | if err != nil {
34 | log.Fatalf("[ERROR] Redis ping failed: %v", err)
35 | }
36 |
37 | log.Printf("[INFO] Redis init success, pong: %s ", pong)
38 | }
39 |
--------------------------------------------------------------------------------
/app/middleware/user_app.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/soxft/openid-go/library/apiutil"
6 | "github.com/soxft/openid-go/library/apputil"
7 | )
8 |
9 | // UserApp 用来检测是否为用户APP
10 | func UserApp() gin.HandlerFunc {
11 | return func(c *gin.Context) {
12 | api := apiutil.New(c)
13 |
14 | appID := c.Param("appid")
15 | if appID == "" {
16 | api.Abort200("app id is empty", "middleware.user_app.app_id_empty")
17 | return
18 | }
19 |
20 | var userID int
21 | if userID = c.GetInt("userId"); userID == 0 {
22 | api.Abort401("Unauthorized", "middleware.user_app.user_id_empty")
23 | return
24 | }
25 |
26 | if i, err := apputil.CheckIfUserApp(appID, userID); err != nil {
27 | //log.Printf("check if user app error: %v", err)
28 |
29 | api.Abort401("Unauthorized", "middleware.user_app.error.not_user_app")
30 | return
31 | } else if !i {
32 | api.Abort200("Unauthorized", "middleware.user_app.not_user_app")
33 | return
34 | }
35 |
36 | c.Next()
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/config.tpl:
--------------------------------------------------------------------------------
1 | Server:
2 | Address: 127.0.0.1:8080
3 | Debug: true
4 | Log: true
5 | Title: "X openID"
6 | ServerName: "X openID"
7 | FrontUrl: http://127.0.0.1:8080
8 | Redis:
9 | Address: "127.0.0.1:6379"
10 | Password:
11 | Database: 0
12 | Prefix: openid
13 | MinIdle: 10
14 | MaxIdle: 50
15 | MaxActive: 500
16 | MaxRetries: 3
17 | Mysql:
18 | Address: 127.0.0.1:3306
19 | Username: openid
20 | Password: openid
21 | Database: openid
22 | Charset: utf8mb4
23 | MaxOpen: 200
24 | MaxIdle: 100
25 | MaxLifetime: 240
26 | Aliyun: # Aliyun 邮件推送
27 | Domain: dm.aliyuncs.com
28 | Region: cn-hangzhou
29 | Version: 2015-11-23
30 | AccessKey: AccessKey
31 | AccessSecret: AccessSecret
32 | Email: no-reply@mail.example.com
33 | Smtp: # SMTP配置
34 | Host: smtp.example.com
35 | Port: 465
36 | Secure: true
37 | Username: username
38 | Password: password
39 | Jwt:
40 | Secret: "jwt_secret"
41 | Developer:
42 | AppLimit: 10
43 | Github:
44 | ClientID: "github_client_id"
45 | ClientSecret: "github_client_secret"
--------------------------------------------------------------------------------
/library/toolutil/rand.go:
--------------------------------------------------------------------------------
1 | package toolutil
2 |
3 | import (
4 | "math/rand"
5 | "time"
6 | )
7 |
8 | const strList string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
9 |
10 | func RandStr(length int) string {
11 | var result []byte
12 |
13 | r := rand.New(rand.NewSource(time.Now().Unix()))
14 |
15 | for i := 0; i < length; i++ {
16 | result = append(result, strList[r.Int63()%int64(len(strList))])
17 | }
18 | return string(result)
19 | }
20 |
21 | func RandInt(length int) int {
22 | r := rand.New(rand.NewSource(time.Now().Unix()))
23 |
24 | var code int
25 | for i := 0; i < length; i++ {
26 | code += r.Intn(10)
27 | }
28 | return code
29 | }
30 |
31 | func RandStrInt(length int) string {
32 | var result []byte
33 |
34 | r := rand.New(rand.NewSource(time.Now().Unix()))
35 |
36 | for i := 0; i < length; i++ {
37 | j := r.Intn(4)
38 | if j%2 == 0 {
39 | result = append(result, strList[r.Int63()%int64(len(strList))])
40 | } else {
41 | result = append(result, byte(r.Intn(10)+'0'))
42 | }
43 | }
44 | return string(result)
45 | }
46 |
--------------------------------------------------------------------------------
/app/dto/common.go:
--------------------------------------------------------------------------------
1 | package dto
2 |
3 | import "github.com/gin-gonic/gin"
4 |
5 | type PageRequest struct {
6 | Page int `json:"page" binding:"min=1"`
7 | PerPage int `json:"per_page" binding:"min=1,max=100"`
8 | }
9 |
10 | type ListResponse struct {
11 | Total int `json:"total"`
12 | List interface{} `json:"list"`
13 | }
14 |
15 | type SuccessResponse struct {
16 | Code int `json:"code"`
17 | Message string `json:"message"`
18 | Data interface{} `json:"data,omitempty"`
19 | }
20 |
21 | type ErrorResponse struct {
22 | Code int `json:"code"`
23 | Message string `json:"message"`
24 | Data interface{} `json:"data,omitempty"`
25 | }
26 |
27 | type TokenResponse struct {
28 | Token string `json:"token"`
29 | }
30 |
31 | type IDResponse struct {
32 | ID interface{} `json:"id"`
33 | }
34 |
35 | type DataResponse struct {
36 | Data interface{} `json:"data"`
37 | }
38 |
39 | func BindJSON(c *gin.Context, obj interface{}) error {
40 | if err := c.ShouldBindJSON(obj); err != nil {
41 | return err
42 | }
43 | return nil
44 | }
--------------------------------------------------------------------------------
/app/dto/user.go:
--------------------------------------------------------------------------------
1 | package dto
2 |
3 | // UserPasswordUpdateRequest 用户更新密码请求
4 | type UserPasswordUpdateRequest struct {
5 | OldPassword string `json:"old_password" binding:"required"`
6 | NewPassword string `json:"new_password" binding:"required,min=8,max=64"`
7 | }
8 |
9 | // UserEmailUpdateCodeRequest 发送邮箱更新验证码请求
10 | type UserEmailUpdateCodeRequest struct {
11 | Email string `json:"email" binding:"required,email"`
12 | }
13 |
14 | // UserEmailUpdateRequest 更新邮箱请求
15 | type UserEmailUpdateRequest struct {
16 | Email string `json:"email" binding:"required,email"`
17 | Code string `json:"code" binding:"required"`
18 | }
19 |
20 | // UserInfoResponse 用户信息响应
21 | type UserInfoResponse struct {
22 | ID int `json:"id"`
23 | Username string `json:"username"`
24 | Email string `json:"email"`
25 | RegTime int64 `json:"reg_time"`
26 | LastTime int64 `json:"last_time"`
27 | }
28 |
29 | // UserStatusResponse 用户状态响应
30 | type UserStatusResponse struct {
31 | Login bool `json:"login"`
32 | Username string `json:"username,omitempty"`
33 | Email string `json:"email,omitempty"`
34 | }
--------------------------------------------------------------------------------
/library/mailutil/aliyun.go:
--------------------------------------------------------------------------------
1 | package mailutil
2 |
3 | import (
4 | "github.com/aliyun/alibaba-cloud-sdk-go/sdk"
5 | "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests"
6 | "github.com/soxft/openid-go/config"
7 | )
8 |
9 | func SendByAliyun(mail Mail) error {
10 | client, err := sdk.NewClientWithAccessKey(config.Aliyun.Region, config.Aliyun.AccessKey, config.Aliyun.AccessSecret)
11 | if err != nil {
12 | return err
13 | }
14 | request := requests.NewCommonRequest()
15 | request.Method = "POST"
16 | request.Scheme = "https" // https | http
17 | request.Domain = config.Aliyun.Domain
18 | request.Version = config.Aliyun.Version
19 | request.ApiName = "SingleSendMail"
20 | request.QueryParams["ToAddress"] = mail.ToAddress
21 | request.QueryParams["Subject"] = mail.Subject + " - " + config.Server.Title
22 | request.QueryParams["HtmlBody"] = mail.Content
23 | request.QueryParams["FromAlias"] = config.Server.Title
24 | request.QueryParams["AccountName"] = config.Aliyun.Email
25 | request.QueryParams["AddressType"] = "1"
26 | request.QueryParams["ReplyToAddress"] = "true"
27 |
28 | _, err = client.ProcessCommonRequest(request)
29 | return err
30 | }
31 |
--------------------------------------------------------------------------------
/app/dto/app.go:
--------------------------------------------------------------------------------
1 | package dto
2 |
3 | // AppCreateRequest 创建应用请求
4 | type AppCreateRequest struct {
5 | AppName string `json:"app_name" binding:"required"`
6 | }
7 |
8 | // AppEditRequest 编辑应用请求
9 | type AppEditRequest struct {
10 | AppName string `json:"app_name" binding:"required"`
11 | AppGateway string `json:"app_gateway" binding:"required,max=200"`
12 | }
13 |
14 | // AppListRequest 获取应用列表请求
15 | type AppListRequest struct {
16 | Page int `json:"page,omitempty" form:"page" binding:"omitempty,min=1"`
17 | PerPage int `json:"per_page,omitempty" form:"per_page" binding:"omitempty,min=1,max=100"`
18 | }
19 |
20 | // AppListResponse 应用列表响应
21 | type AppListResponse struct {
22 | Total int `json:"total"`
23 | List interface{} `json:"list"`
24 | }
25 |
26 | // AppInfoResponse 应用信息响应
27 | type AppInfoResponse struct {
28 | AppID string `json:"app_id"`
29 | AppName string `json:"app_name"`
30 | AppGateway string `json:"app_gateway"`
31 | AppSecret string `json:"app_secret,omitempty"`
32 | CreateTime int64 `json:"create_time,omitempty"`
33 | UpdateTime int64 `json:"update_time,omitempty"`
34 | }
35 |
36 | // AppSecretResponse 重置密钥响应
37 | type AppSecretResponse struct {
38 | Secret string `json:"secret"`
39 | }
--------------------------------------------------------------------------------
/library/userutil/struct.go:
--------------------------------------------------------------------------------
1 | package userutil
2 |
3 | import (
4 | "errors"
5 | "github.com/golang-jwt/jwt/v4"
6 | )
7 |
8 | //type User struct {
9 | // Username string
10 | // Password string
11 | // Email string
12 | // RegTime int64
13 | // RegIp string
14 | // LastTime int64
15 | // LastIp string
16 | //}
17 | //
18 | //type UserLastInfo struct {
19 | // LastIp string
20 | // LastTime int64
21 | //}
22 |
23 | type JwtClaims struct {
24 | ID string `json:"jti,omitempty"`
25 | ExpireAt int64 `json:"exp,omitempty"`
26 | IssuedAt int64 `json:"iat,omitempty"`
27 | Issuer string `json:"iss,omitempty"`
28 | Username string `json:"username"`
29 | UserId int `json:"userId"`
30 | Email string `json:"email"`
31 | LastTime int64 `json:"lastTime"`
32 | jwt.RegisteredClaims
33 | }
34 |
35 | type UserInfo struct {
36 | Username string `json:"username"`
37 | UserId int `json:"userId"`
38 | Email string `json:"email"`
39 | LastTime int64 `json:"lastTime"`
40 | }
41 |
42 | var (
43 | ErrEmailExists = errors.New("mailExists")
44 | ErrUsernameExists = errors.New("usernameExists")
45 | ErrPasswd = errors.New("password not correct")
46 | ErrDatabase = errors.New("database error")
47 | ErrJwtExpired = errors.New("jwt is expired")
48 | )
49 |
--------------------------------------------------------------------------------
/library/apiutil/api.go:
--------------------------------------------------------------------------------
1 | package apiutil
2 |
3 | import "github.com/gin-gonic/gin"
4 |
5 | func New(ctx *gin.Context) *Api {
6 | return &Api{
7 | Ctx: ctx,
8 | }
9 | }
10 |
11 | func (c *Api) Out(httpCode int, success bool, msg string, data interface{}) {
12 | c.Ctx.JSON(httpCode, gin.H{
13 | "success": success,
14 | "message": msg,
15 | "data": data,
16 | })
17 | }
18 |
19 | func (c *Api) Success(msg string) {
20 | c.Out(200, true, msg, gin.H{})
21 | }
22 |
23 | func (c *Api) SuccessWithData(msg string, data interface{}) {
24 | c.Out(200, true, msg, data)
25 | }
26 |
27 | func (c *Api) Fail(msg string) {
28 | c.Out(200, false, msg, gin.H{})
29 | }
30 |
31 | func (c *Api) FailWithData(msg string, data interface{}) {
32 | c.Out(200, false, msg, data)
33 | }
34 |
35 | // httpCode
36 |
37 | func (c *Api) FailWithHttpCode(httpCode int, msg string) {
38 | c.Out(httpCode, false, msg, gin.H{})
39 | }
40 |
41 | // Abort
42 |
43 | func (c *Api) Abort(httpCode int, msg string, errors string) {
44 | c.Ctx.AbortWithStatusJSON(httpCode, gin.H{
45 | "success": false,
46 | "message": msg,
47 | "data": gin.H{
48 | "error": errors,
49 | },
50 | })
51 | }
52 |
53 | func (c *Api) Abort401(msg string, errors string) {
54 | c.Abort(401, msg, errors)
55 | }
56 |
57 | func (c *Api) Abort200(msg string, errors string) {
58 | c.Abort(200, msg, errors)
59 | }
60 |
--------------------------------------------------------------------------------
/api/version_one/info.go:
--------------------------------------------------------------------------------
1 | package version_one
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/soxft/openid-go/api/version_one/helper"
8 | "github.com/soxft/openid-go/library/apiutil"
9 | "github.com/soxft/openid-go/library/apputil"
10 | )
11 |
12 | type InfoRequest struct {
13 | Token string `json:"token" binding:"required"`
14 | AppId string `json:"appid" binding:"required"`
15 | AppSecret string `json:"app_secret" binding:"required"`
16 | }
17 |
18 | type InfoResponse struct {
19 | OpenId string `json:"openId"`
20 | UniqueId string `json:"uniqueId"`
21 | }
22 |
23 | // Info
24 | // @description 获取openid 和 uniqueId
25 | // @route POST /v1/info
26 | func Info(c *gin.Context) {
27 | api := apiutil.New(c)
28 |
29 | var req InfoRequest
30 | if err := c.ShouldBindJSON(&req); err != nil {
31 | api.Fail("Invalid params")
32 | return
33 | }
34 |
35 | // 判断appId与appSecret是否正确
36 | if err := apputil.CheckAppSecret(req.AppId, req.AppSecret); err != nil {
37 | api.Fail(err.Error())
38 | return
39 | }
40 |
41 | // 检测token是否正确 并获取userId
42 | userId, err := helper.GetUserIdByToken(c, req.AppId, req.Token)
43 | if err != nil {
44 | if errors.Is(err, helper.ErrTokenNotExists) {
45 | api.Fail("Token not exists")
46 | return
47 | }
48 | api.Fail(err.Error())
49 | return
50 | }
51 | userIds, err := helper.GetUserIds(req.AppId, userId)
52 | if err != nil {
53 | api.Fail(err.Error())
54 | return
55 | }
56 | // delete token
57 | _ = helper.DeleteToken(c, req.AppId, req.Token)
58 | api.SuccessWithData("success", InfoResponse{
59 | OpenId: userIds.OpenId,
60 | UniqueId: userIds.UniqueId,
61 | })
62 | }
63 |
--------------------------------------------------------------------------------
/library/mailutil/beacon.go:
--------------------------------------------------------------------------------
1 | package mailutil
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/soxft/openid-go/config"
6 | "github.com/soxft/openid-go/library/toolutil"
7 | "github.com/soxft/openid-go/process/redisutil"
8 | "time"
9 | )
10 |
11 | // CreateBeacon
12 | // @description: 创建邮件发送信标
13 | func CreateBeacon(c *gin.Context, mail string, timeout time.Duration) error {
14 | _redis := redisutil.RDB
15 |
16 | ipKey, mailKey := getRKeys(c, mail)
17 |
18 | _redis.SetEx(c, ipKey, "1", timeout)
19 | _redis.SetEx(c, mailKey, "1", timeout)
20 | return nil
21 | }
22 |
23 | // DeleteBeacon
24 | // @description: 手动删除邮件创建新信标
25 | func DeleteBeacon(c *gin.Context, mail string) {
26 | _redis := redisutil.RDB
27 |
28 | ipKey, mailKey := getRKeys(c, mail)
29 |
30 | _redis.Del(c, ipKey)
31 | _redis.Del(c, mailKey)
32 | }
33 |
34 | // CheckBeacon
35 | // @description: 检查邮件发送信标 避免频繁发信
36 | func CheckBeacon(c *gin.Context, mail string) (bool, error) {
37 | _redis := redisutil.RDB
38 |
39 | ipKey, mailKey := getRKeys(c, mail)
40 |
41 | if ipExists, err := _redis.Exists(c, ipKey).Result(); err != nil || ipExists != 1 {
42 | return false, err
43 | }
44 |
45 | if mailExists, err := _redis.Exists(c, mailKey).Result(); err != nil || mailExists != 1 {
46 | return false, err
47 | }
48 |
49 | return true, nil
50 | }
51 |
52 | func generateUnique(c *gin.Context) string {
53 | // get user ip
54 | userIp := c.ClientIP()
55 | userAgent := c.Request.UserAgent()
56 | return toolutil.Md5(userIp + userAgent)
57 | }
58 |
59 | func getRKeys(c *gin.Context, mail string) (string, string) {
60 | unique := generateUnique(c)
61 | redisPrefix := config.Redis.Prefix
62 |
63 | ipKey := redisPrefix + ":beacon:ip:" + unique
64 | mailKey := redisPrefix + ":beacon:mail:" + toolutil.Md5(mail)
65 |
66 | return ipKey, mailKey
67 | }
68 |
--------------------------------------------------------------------------------
/library/codeutil/code.go:
--------------------------------------------------------------------------------
1 | package codeutil
2 |
3 | import (
4 | "context"
5 | "github.com/soxft/openid-go/config"
6 | "github.com/soxft/openid-go/library/toolutil"
7 | "github.com/soxft/openid-go/process/redisutil"
8 | "math/rand"
9 | "strconv"
10 | "time"
11 | )
12 |
13 | func New(ctx context.Context) *VerifyCode {
14 | return &VerifyCode{
15 | ctx: ctx,
16 | }
17 | }
18 |
19 | // Create
20 | // @description: create verify code
21 | func (c VerifyCode) Create(length int) string {
22 | rand.Seed(time.Now().UnixNano() + int64(rand.Intn(100)))
23 |
24 | var code string
25 | for i := 0; i < length; i++ {
26 | code += strconv.Itoa(rand.Intn(10))
27 | }
28 | return code
29 | }
30 |
31 | // Save
32 | // @description: save verify code 存储验证码
33 | // timeout: expire time (second)
34 | func (c VerifyCode) Save(topic string, email string, code string, timeout time.Duration) error {
35 | _redis := redisutil.RDB
36 |
37 | redisKey := config.RedisPrefix + ":code:" + topic + ":" + toolutil.Md5(email)
38 |
39 | return _redis.SetEx(c.ctx, redisKey, toolutil.Md5(code), timeout).Err()
40 | }
41 |
42 | // Check
43 | // @description: 判断验证码是否正确
44 | func (c VerifyCode) Check(topic string, email string, code string) (bool, error) {
45 | _redis := redisutil.RDB
46 |
47 | redisKey := config.RedisPrefix + ":code:" + topic + ":" + toolutil.Md5(email)
48 | if realCode, err := _redis.Get(c.ctx, redisKey).Result(); err != nil {
49 | return false, err
50 | } else if realCode == toolutil.Md5(code) {
51 | // delete key
52 | return true, nil
53 | }
54 |
55 | return false, nil
56 | }
57 |
58 | // Consume
59 | // @description: 消费(删除)验证码
60 | func (c VerifyCode) Consume(topic string, email string) {
61 | _redis := redisutil.RDB
62 |
63 | redisKey := config.RedisPrefix + ":code:" + topic + ":" + toolutil.Md5(email)
64 |
65 | _redis.Del(c.ctx, redisKey)
66 | }
67 |
--------------------------------------------------------------------------------
/process/dbutil/dbutil.go:
--------------------------------------------------------------------------------
1 | package dbutil
2 |
3 | import (
4 | "fmt"
5 | "github.com/soxft/openid-go/app/model"
6 | "github.com/soxft/openid-go/config"
7 | "gorm.io/driver/mysql"
8 | "gorm.io/gorm"
9 | "gorm.io/gorm/logger"
10 | "log"
11 | "os"
12 | "time"
13 | )
14 |
15 | var D *gorm.DB
16 |
17 | func Init() {
18 | m := config.Mysql
19 | log.Printf("[INFO] Mysql trying connect to tcp://%s:%s/%s", m.User, m.Addr, m.Db)
20 |
21 | dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=%s", m.User, m.Pwd, m.Addr, m.Db, m.Charset)
22 |
23 | var logMode = logger.Warn
24 | if config.Server.Debug {
25 | logMode = logger.Info
26 | }
27 |
28 | sqlLogger := logger.New(
29 | log.New(os.Stderr, "\r\n", log.LstdFlags), // io writer(日志输出的目标,前缀和日志包含的内容——译者注)
30 | logger.Config{
31 | SlowThreshold: time.Millisecond * 200, // 慢 SQL 阈值
32 | LogLevel: logMode, // 日志级别
33 | IgnoreRecordNotFoundError: true, // 忽略ErrRecordNotFound(记录未找到)错误
34 | Colorful: true, // 禁用彩色打印
35 | },
36 | )
37 |
38 | var err error
39 | D, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
40 | Logger: sqlLogger,
41 | })
42 | if err != nil {
43 | log.Fatalf("mysql error: %v", err)
44 | }
45 |
46 | sqlDb, err := D.DB()
47 | if err != nil {
48 | log.Fatalf("mysql get db error: %v", err)
49 | }
50 |
51 | sqlDb.SetMaxOpenConns(m.MaxOpen)
52 | sqlDb.SetMaxIdleConns(m.MaxIdle)
53 | sqlDb.SetConnMaxLifetime(time.Duration(m.MaxLifetime) * time.Second)
54 | if err := sqlDb.Ping(); err != nil {
55 | log.Fatalf("mysql connect error: %v", err)
56 | }
57 |
58 | if err := D.AutoMigrate(model.Account{}, model.App{}, model.OpenId{}, model.UniqueId{}, model.PassKey{}); err != nil {
59 | log.Fatalf("mysql migrate error: %v", err)
60 | }
61 |
62 | log.Printf("[INFO] Mysql connect success")
63 | }
64 |
--------------------------------------------------------------------------------
/config/struct.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | type Config struct {
4 | ServerConfig `yaml:"Server"`
5 | RedisConfig `yaml:"Redis"`
6 | MysqlConfig `yaml:"Mysql"`
7 | SmtpConfig `yaml:"Smtp"`
8 | AliyunConfig `yaml:"Aliyun"`
9 | JwtConfig `yaml:"Jwt"`
10 | DeveloperConfig `yaml:"Developer"`
11 | }
12 | type ServerConfig struct {
13 | Addr string `yaml:"Address"`
14 | Debug bool `yaml:"Debug"`
15 | Log bool `yaml:"Log"`
16 | Title string `yaml:"Title"`
17 | Name string `yaml:"ServerName"`
18 | FrontUrl string `yaml:"FrontUrl"`
19 | }
20 |
21 | type RedisConfig struct {
22 | Addr string `yaml:"Address"`
23 | Pwd string `yaml:"Password"`
24 | Db int `yaml:"Database"`
25 | Prefix string `yaml:"Prefix"`
26 | MinIdle int `yaml:"MinIdle"`
27 | MaxIdle int `yaml:"MaxIdle"`
28 | MaxActive int `yaml:"MaxActive"`
29 | MaxRetries int `yaml:"MaxRetries"`
30 | }
31 |
32 | type MysqlConfig struct {
33 | Addr string `yaml:"Address"`
34 | User string `yaml:"Username"`
35 | Pwd string `yaml:"Password"`
36 | Db string `yaml:"Database"`
37 | Charset string `yaml:"Charset"`
38 | MaxOpen int `yaml:"MaxOpen"`
39 | MaxIdle int `yaml:"MaxIdle"`
40 | MaxLifetime int `yaml:"MaxLifetime"`
41 | }
42 |
43 | type SmtpConfig struct {
44 | Host string `yaml:"Host"`
45 | Port int `yaml:"Port"`
46 | Secure bool `yaml:"Secure"`
47 | User string `yaml:"Username"`
48 | Pwd string `yaml:"Password"`
49 | }
50 |
51 | type AliyunConfig struct {
52 | Domain string `yaml:"Domain"`
53 | Region string `yaml:"Region"`
54 | Version string `yaml:"Version"`
55 | AccessKey string `yaml:"AccessKey"`
56 | AccessSecret string `yaml:"AccessSecret"`
57 | Email string `yaml:"Email"`
58 | }
59 |
60 | type JwtConfig struct {
61 | Secret string `yaml:"Secret"`
62 | }
63 |
64 | type DeveloperConfig struct {
65 | AppLimit int `yaml:"AppLimit"`
66 | }
67 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/soxft/openid-go
2 |
3 | go 1.24.0
4 |
5 | toolchain go1.24.9
6 |
7 | require (
8 | github.com/aliyun/alibaba-cloud-sdk-go v1.61.1582
9 | github.com/gin-gonic/gin v1.6.3
10 | github.com/go-webauthn/webauthn v0.14.0
11 | github.com/golang-jwt/jwt/v4 v4.4.3
12 | github.com/redis/go-redis/v9 v9.2.1
13 | golang.org/x/crypto v0.42.0
14 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
15 | gopkg.in/yaml.v2 v2.4.0
16 | gorm.io/driver/mysql v1.5.7
17 | gorm.io/gorm v1.25.7
18 | )
19 |
20 | require (
21 | github.com/cespare/xxhash/v2 v2.2.0 // indirect
22 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
23 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect
24 | github.com/gin-contrib/sse v0.1.0 // indirect
25 | github.com/go-playground/locales v0.14.0 // indirect
26 | github.com/go-playground/universal-translator v0.18.0 // indirect
27 | github.com/go-playground/validator/v10 v10.3.0 // indirect
28 | github.com/go-sql-driver/mysql v1.7.0 // indirect
29 | github.com/go-webauthn/x v0.1.25 // indirect
30 | github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
31 | github.com/golang/protobuf v1.5.2 // indirect
32 | github.com/google/go-tpm v0.9.5 // indirect
33 | github.com/google/uuid v1.6.0 // indirect
34 | github.com/jinzhu/inflection v1.0.0 // indirect
35 | github.com/jinzhu/now v1.1.5 // indirect
36 | github.com/jmespath/go-jmespath v0.4.0 // indirect
37 | github.com/json-iterator/go v1.1.12 // indirect
38 | github.com/leodido/go-urn v1.2.1 // indirect
39 | github.com/mattn/go-isatty v0.0.14 // indirect
40 | github.com/mitchellh/mapstructure v1.5.0 // indirect
41 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
42 | github.com/modern-go/reflect2 v1.0.2 // indirect
43 | github.com/ugorji/go/codec v1.2.7 // indirect
44 | github.com/x448/float16 v0.8.4 // indirect
45 | golang.org/x/sys v0.36.0 // indirect
46 | google.golang.org/protobuf v1.28.0 // indirect
47 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
48 | gopkg.in/ini.v1 v1.66.4 // indirect
49 | )
50 |
--------------------------------------------------------------------------------
/app/dto/passkey.go:
--------------------------------------------------------------------------------
1 | package dto
2 |
3 | // PasskeyRegistrationFinishRequest Passkey注册完成请求
4 | type PasskeyRegistrationFinishRequest struct {
5 | ID string `json:"id" binding:"required"`
6 | RawID string `json:"rawId" binding:"required"`
7 | Type string `json:"type" binding:"required"`
8 | Remark string `json:"remark,omitempty"`
9 | Response struct {
10 | AttestationObject string `json:"attestationObject" binding:"required"`
11 | ClientDataJSON string `json:"clientDataJSON" binding:"required"`
12 | Transports []string `json:"transports,omitempty"`
13 | } `json:"response" binding:"required"`
14 | AuthenticatorAttachment string `json:"authenticatorAttachment,omitempty"`
15 | }
16 |
17 | // PasskeyLoginFinishRequest Passkey登录完成请求
18 | type PasskeyLoginFinishRequest struct {
19 | ID string `json:"id" binding:"required"`
20 | RawID string `json:"rawId" binding:"required"`
21 | Type string `json:"type" binding:"required"`
22 | Response struct {
23 | ClientDataJSON string `json:"clientDataJSON" binding:"required"`
24 | AuthenticatorData string `json:"authenticatorData" binding:"required"`
25 | Signature string `json:"signature" binding:"required"`
26 | UserHandle string `json:"userHandle,omitempty"`
27 | } `json:"response" binding:"required"`
28 | }
29 |
30 | // PasskeyLoginResponse Passkey登录响应
31 | type PasskeyLoginResponse struct {
32 | Token string `json:"token"`
33 | PasskeyID string `json:"passkeyId"`
34 | Username string `json:"username"`
35 | Email string `json:"email"`
36 | }
37 |
38 | // PasskeyRegistrationResponse Passkey注册响应
39 | type PasskeyRegistrationResponse struct {
40 | PasskeyID string `json:"passkeyId"`
41 | }
42 |
43 | // PasskeySummaryResponse Passkey摘要响应
44 | type PasskeySummaryResponse struct {
45 | ID int `json:"id"`
46 | Remark string `json:"remark,omitempty"`
47 | CreatedAt int64 `json:"created_at"`
48 | LastUsedAt int64 `json:"last_used_at"`
49 | CloneWarning bool `json:"clone_warning"`
50 | SignCount uint32 `json:"sign_count"`
51 | Transports []string `json:"transports,omitempty"`
52 | }
--------------------------------------------------------------------------------
/api/version_one/code.go:
--------------------------------------------------------------------------------
1 | package version_one
2 |
3 | import (
4 | "errors"
5 | "log"
6 | "net/url"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/soxft/openid-go/api/version_one/helper"
10 | "github.com/soxft/openid-go/library/apiutil"
11 | "github.com/soxft/openid-go/library/apputil"
12 | )
13 |
14 | type CodeRequest struct {
15 | AppId string `json:"appid" binding:"required"`
16 | RedirectUri string `json:"redirect_uri" binding:"required"`
17 | }
18 |
19 | type CodeResponse struct {
20 | Token string `json:"token"`
21 | RedirectTo string `json:"redirect_to"`
22 | }
23 |
24 | // Code
25 | // @description 处理登录xhr请求 获取code并跳转到redirect_uri
26 | // @route GET /v1/code
27 | func Code(c *gin.Context) {
28 | api := apiutil.New(c)
29 |
30 | var req CodeRequest
31 | if err := c.ShouldBindJSON(&req); err != nil {
32 | api.Fail("Invalid params")
33 | return
34 | }
35 |
36 | // 检测 redirect_uri 是否为app所对应的
37 | var err error
38 | var redirectUriDomain *url.URL
39 | if redirectUriDomain, err = url.Parse(req.RedirectUri); err != nil {
40 | api.Fail("Invalid redirect_uri")
41 | return
42 | } else if redirectUriDomain.Host == "" {
43 | api.Fail("Invalid redirect_uri")
44 | return
45 | }
46 |
47 | // get app Info
48 | var appInfo apputil.AppFullInfoStruct
49 | if appInfo, err = apputil.GetAppInfo(req.AppId); err != nil {
50 | if errors.Is(err, apputil.ErrAppNotExist) {
51 | api.Fail("app not exist")
52 | return
53 | }
54 | api.Fail("system error")
55 | return
56 | }
57 |
58 | // 获取 app gateway
59 | appGateWay := appInfo.AppGateway
60 | if appGateWay == "" {
61 | api.Fail("Invalid appGateWay, setting it first")
62 | return
63 | }
64 |
65 | // 判断是否一致
66 | if !apputil.CheckRedirectUriIsMatchUserGateway(redirectUriDomain.Host, appGateWay) {
67 | api.FailWithData("redirect_uri is not match with appGateWay", gin.H{
68 | "legal": appGateWay,
69 | "given": redirectUriDomain.Host,
70 | })
71 | return
72 | }
73 | token, err := helper.GenerateToken(c, req.AppId, c.GetInt("userId"))
74 | if err != nil {
75 | log.Printf("[ERROR] get app info error: %s", err.Error())
76 | api.Fail("system error")
77 | return
78 | }
79 |
80 | api.SuccessWithData("success", CodeResponse{
81 | Token: token,
82 | RedirectTo: req.RedirectUri + "?token=" + token,
83 | })
84 | }
85 |
--------------------------------------------------------------------------------
/library/mq/mq.go:
--------------------------------------------------------------------------------
1 | package mq
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "github.com/redis/go-redis/v9"
7 | "log"
8 | "sync"
9 | "time"
10 | )
11 |
12 | func New(c context.Context, redis *redis.Client, maxRetries int) MessageQueue {
13 | return &QueueArgs{
14 | redis: redis,
15 | maxRetries: maxRetries,
16 | ctx: c,
17 | }
18 | }
19 |
20 | func (q *QueueArgs) Publish(topic string, msg string, delay int64) error {
21 | _redis := q.redis
22 |
23 | data := &MsgArgs{
24 | Msg: msg,
25 | DelayAt: delay + time.Now().Unix(),
26 | Retry: 0,
27 | }
28 | if _data, err := json.Marshal(data); err != nil {
29 | return err
30 | } else {
31 | return _redis.LPush(q.ctx, "rmq:"+topic, string(_data)).Err()
32 | }
33 | }
34 |
35 | func (q *QueueArgs) Subscribe(topic string, processes int, handler func(data string)) {
36 | for i := 0; i < processes; i++ {
37 | go func() {
38 | _redis := q.redis
39 | defer func() {
40 | // handle error
41 | if err := recover(); err != nil {
42 | q.Subscribe(topic, 1, handler)
43 | }
44 | }()
45 |
46 | // 阻塞
47 | wg := sync.WaitGroup{}
48 | for {
49 | _data, err := _redis.BRPop(q.ctx, 1*time.Second, "rmq:"+topic).Result()
50 | if err != nil || _data == nil {
51 | continue
52 | }
53 |
54 | var _dataString = _data[1]
55 | var _msg MsgArgs
56 | if err := json.Unmarshal([]byte(_dataString), &_msg); err != nil {
57 | continue
58 | }
59 |
60 | wg.Add(1)
61 | // execute handler
62 | go func(_msg MsgArgs) {
63 | defer func() {
64 | // retry if error
65 | wg.Done()
66 | if err := recover(); err != nil {
67 | log.Printf("[ERROR] mq handler: %s", err)
68 | if _data, err := json.Marshal(_msg); err != nil {
69 | return
70 | } else {
71 | // max retry
72 | if _msg.Retry > q.maxRetries {
73 | _redis.LPush(q.ctx, "rmq:"+topic+"failed", _data)
74 | return
75 | }
76 | _redis.LPush(q.ctx, "rmq:"+topic, _data)
77 | }
78 | _msg.Retry++
79 | }
80 | }()
81 | // delay 重新放入队列
82 | if _msg.DelayAt > time.Now().Unix() {
83 | if err := _redis.LPush(q.ctx, "rmq:"+topic, _dataString).Err(); err != nil {
84 | log.Printf("[ERROR] mq delay lpush: %s", err)
85 | }
86 | return
87 | }
88 | handler(_msg.Msg)
89 |
90 | // prevent loop
91 | time.Sleep(time.Millisecond * 500)
92 | }(_msg)
93 | // wait for handler
94 | wg.Wait()
95 | }
96 | }()
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/process/webutil/route.go:
--------------------------------------------------------------------------------
1 | package webutil
2 |
3 | import (
4 | "github.com/gin-gonic/gin"
5 | "github.com/soxft/openid-go/api/version_one"
6 | "github.com/soxft/openid-go/app/controller"
7 | "github.com/soxft/openid-go/app/middleware"
8 | "github.com/soxft/openid-go/config"
9 | "github.com/soxft/openid-go/library/apiutil"
10 | )
11 |
12 | func initRoute(r *gin.Engine) {
13 | r.Use(gin.Recovery())
14 | if config.Server.Log {
15 | r.Use(gin.Logger())
16 | }
17 | r.Use(middleware.Cors())
18 | {
19 | {
20 | // ping
21 | r.HEAD("/ping", controller.Ping)
22 | r.GET("/ping", controller.Ping)
23 |
24 | // register
25 | r.POST("/register/code", controller.RegisterCode)
26 | r.POST("/register", controller.RegisterSubmit)
27 |
28 | // login
29 | r.POST("/login", controller.Login)
30 | }
31 |
32 | user := r.Group("/user")
33 | {
34 | user.Use(middleware.AuthPermission())
35 | user.GET("/status", controller.UserStatus)
36 | user.GET("/info", controller.UserInfo)
37 | user.POST("/logout", controller.UserLogout)
38 | user.PATCH("/password/update", controller.UserPasswordUpdate)
39 | user.POST("/email/update/code", controller.UserEmailUpdateCode)
40 | user.PATCH("/email/update", controller.UserEmailUpdate)
41 | }
42 |
43 | pass := r.Group("/passkey")
44 | {
45 | // Login endpoints (no auth required)
46 | pass.GET("/login/options", controller.PasskeyLoginOptions)
47 | pass.POST("/login", controller.PasskeyLoginFinish)
48 |
49 | pass.Use(middleware.AuthPermission())
50 | // Registration endpoints (auth required)
51 | pass.GET("/register/options", controller.PasskeyRegistrationOptions)
52 | pass.POST("/register", controller.PasskeyRegistrationFinish)
53 |
54 | // Management endpoints (auth required)
55 | pass.GET("", controller.PasskeyList)
56 | pass.DELETE(":id", controller.PasskeyDelete)
57 | }
58 |
59 | app := r.Group("/app")
60 | {
61 | app.Use(middleware.AuthPermission())
62 | app.GET("/list", controller.AppGetList)
63 | app.POST("/create", controller.AppCreate)
64 |
65 | // 判断 App 归属中间件
66 | app.Use(middleware.UserApp())
67 | app.PUT("/id/:appid", controller.AppEdit)
68 | app.DELETE("/id/:appid", controller.AppDel)
69 | app.GET("/id/:appid", controller.AppInfo)
70 |
71 | app.PUT("/id/:appid/secret", controller.AppReGenerateSecret)
72 | }
73 |
74 | forget := r.Group("/forget")
75 | {
76 | forget.POST("/password/code", controller.ForgetPasswordCode)
77 | forget.PATCH("/password/update", controller.ForgetPasswordUpdate)
78 | }
79 |
80 | v1 := r.Group("/v1")
81 | {
82 | v1.GET("/login", version_one.Login)
83 | v1.POST("/code", middleware.AuthPermission(), version_one.Code)
84 | v1.POST("/info", version_one.Info)
85 | v1.GET("/app/info/:appid", version_one.AppInfo)
86 | }
87 |
88 | r.NoRoute(noRoute)
89 | }
90 | }
91 |
92 | func noRoute(c *gin.Context) {
93 | api := apiutil.New(c)
94 |
95 | api.FailWithHttpCode(404, "Route not exists")
96 | }
97 |
--------------------------------------------------------------------------------
/docs/passkey-frontend-todo.md:
--------------------------------------------------------------------------------
1 | # Passkey 前端改造清单
2 |
3 | 面向现有业务前端维护者,列出上线 Passkey 所需的改动项与核对项。
4 |
5 | ## 1. 路由与鉴权
6 | - 确保用户中心(或登录页)在调用 Passkey 接口前已获取有效 JWT,并在请求头携带 `Authorization: Bearer `。
7 | - 如果后端域名为 `https://local.bsz.com:3000`,请核对浏览器调用时的 `origin` 与后端配置一致(配置项 `config.yaml` → `server.frontUrl`)。
8 |
9 | ## 2. 注册流程(账号设置界面)
10 | 1. 点击“绑定 Passkey”触发注册流程:
11 | - `GET /passkey/register/options` → 取得 `PublicKeyCredentialCreationOptions`
12 | - 调用 `navigator.credentials.create({ publicKey: options })`
13 | 2. 将返回的 `PublicKeyCredential` 转换为 JSON 并提交:
14 | - `POST /passkey/register`,`Content-Type: application/json`
15 | 3. 注册成功后刷新列表或提示“绑定成功”。
16 |
17 | > ⚠️ Safari、Chrome 等浏览器要求页面为 HTTPS 且顶级域一致;请在本地调试时使用 HTTPS。
18 |
19 | ## 3. 登录流程(登录页 Passkey 按钮)
20 | 1. 登录表单新增“使用 Passkey 登录”按钮。
21 | 2. 点击按钮流程:
22 | - `GET /passkey/login/options`
23 | - `navigator.credentials.get({ publicKey: options })`
24 | - `POST /passkey/login`
25 | - 如成功,后端会返回新的 JWT(`data.token`),应覆盖现有登录态并跳转后台首页。
26 | 3. 若接口返回 `未绑定 Passkey`,提示用户先在个人中心绑定。
27 |
28 | ## 4. 凭证管理页(可选)
29 | - 通过 `GET /passkey` 展示当前账号绑定的 Passkey 列表:
30 | - 展示字段建议:创建时间、最近使用时间、是否存在 Clone Warning。
31 | - 删除按钮调用 `DELETE /passkey/:id`。
32 | - 注册成功后自动刷新本列表。
33 |
34 | ## 5. JS 工具函数
35 | - 确保项目中存在以下工具或等效处理,用于序列化 WebAuthn `ArrayBuffer`:
36 |
37 | ```ts
38 | const toBase64Url = (buffer: ArrayBuffer): string => {
39 | const bytes = new Uint8Array(buffer);
40 | let binary = "";
41 | bytes.forEach(b => (binary += String.fromCharCode(b)));
42 | return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
43 | };
44 |
45 | export const publicKeyCredentialToJSON = (cred: PublicKeyCredential) => ({
46 | id: cred.id,
47 | rawId: toBase64Url(cred.rawId),
48 | type: cred.type,
49 | clientExtensionResults: cred.getClientExtensionResults(),
50 | response: {
51 | attestationObject: cred.response.attestationObject
52 | ? toBase64Url((cred.response as AuthenticatorAttestationResponse).attestationObject)
53 | : undefined,
54 | clientDataJSON: toBase64Url(cred.response.clientDataJSON),
55 | authenticatorData: cred.response.authenticatorData
56 | ? toBase64Url((cred.response as AuthenticatorAssertionResponse).authenticatorData)
57 | : undefined,
58 | signature: cred.response.signature
59 | ? toBase64Url((cred.response as AuthenticatorAssertionResponse).signature)
60 | : undefined,
61 | userHandle: cred.response.userHandle
62 | ? toBase64Url((cred.response as AuthenticatorAssertionResponse).userHandle!)
63 | : undefined,
64 | },
65 | });
66 | ```
67 |
68 | ## 6. 错误处理与 UX
69 | - 注册/登录出现 `挑战已过期,请重试` → 自动重新获取 options 并提醒用户重新操作。
70 | - 捕获 `navigator.credentials.*` 抛出的 `NotAllowedError`(用户取消)等异常,提示“操作已取消”。
71 | - 若浏览器不支持 WebAuthn(`window.PublicKeyCredential` 不存在),隐藏按钮或给出提示。
72 |
73 | ## 7. 联调与测试
74 | - 浏览器控制台搜索网络请求是否正确发送 JSON(非 `application/x-www-form-urlencoded`)。
75 | - 本地调试请确保后端 Redis 已启动,避免接口返回挑战不存在。
76 | - 常用测试场景:
77 | 1. 首次绑定 Passkey
78 | 2. 重复绑定(应覆盖旧记录)
79 | 3. 登录成功获取新 JWT
80 | 4. 删除 Passkey 后重新登录(应提示无绑定)
81 |
82 | 完成以上事项后,即可在前端完整支持 Passkey 注册与登录。
--------------------------------------------------------------------------------
/library/passkey/session.go:
--------------------------------------------------------------------------------
1 | package passkey
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "time"
9 |
10 | "github.com/go-webauthn/webauthn/webauthn"
11 | "github.com/redis/go-redis/v9"
12 | "github.com/soxft/openid-go/config"
13 | "github.com/soxft/openid-go/process/redisutil"
14 | )
15 |
16 | func registrationSessionKey(userID int) string {
17 | return fmt.Sprintf("%s:passkey:register:%d", config.RedisPrefix, userID)
18 | }
19 |
20 | func loginSessionKey(userID int) string {
21 | return fmt.Sprintf("%s:passkey:login:%d", config.RedisPrefix, userID)
22 | }
23 |
24 | func storeRegistrationSession(ctx context.Context, userID int, data *webauthn.SessionData, ttl time.Duration) error {
25 | return storeSession(ctx, registrationSessionKey(userID), data, ttl)
26 | }
27 |
28 | func loadRegistrationSession(ctx context.Context, userID int) (*webauthn.SessionData, error) {
29 | return loadSession(ctx, registrationSessionKey(userID))
30 | }
31 |
32 | func deleteRegistrationSession(ctx context.Context, userID int) error {
33 | return deleteSession(ctx, registrationSessionKey(userID))
34 | }
35 |
36 | func storeLoginSession(ctx context.Context, userID int, data *webauthn.SessionData, ttl time.Duration) error {
37 | return storeSession(ctx, loginSessionKey(userID), data, ttl)
38 | }
39 |
40 | func loadLoginSession(ctx context.Context, userID int) (*webauthn.SessionData, error) {
41 | return loadSession(ctx, loginSessionKey(userID))
42 | }
43 |
44 | func deleteLoginSession(ctx context.Context, userID int) error {
45 | return deleteSession(ctx, loginSessionKey(userID))
46 | }
47 |
48 | func storeSession(ctx context.Context, key string, data *webauthn.SessionData, ttl time.Duration) error {
49 | if redisutil.RDB == nil {
50 | return fmt.Errorf("redis not initialized")
51 | }
52 | payload, err := json.Marshal(data)
53 | if err != nil {
54 | return err
55 | }
56 | return redisutil.RDB.Set(ctx, key, payload, ttl).Err()
57 | }
58 |
59 | func loadSession(ctx context.Context, key string) (*webauthn.SessionData, error) {
60 | if redisutil.RDB == nil {
61 | return nil, fmt.Errorf("redis not initialized")
62 | }
63 | raw, err := redisutil.RDB.Get(ctx, key).Bytes()
64 | if err != nil {
65 | if errors.Is(err, redis.Nil) {
66 | return nil, ErrSessionNotFound
67 | }
68 | return nil, err
69 | }
70 | var session webauthn.SessionData
71 | if err := json.Unmarshal(raw, &session); err != nil {
72 | return nil, err
73 | }
74 | return &session, nil
75 | }
76 |
77 | func deleteSession(ctx context.Context, key string) error {
78 | if redisutil.RDB == nil {
79 | return fmt.Errorf("redis not initialized")
80 | }
81 | return redisutil.RDB.Del(ctx, key).Err()
82 | }
83 |
84 | // 通用会话管理函数
85 | func storeGenericSession(ctx context.Context, key string, data *webauthn.SessionData, ttl time.Duration) error {
86 | fullKey := fmt.Sprintf("%s:%s", config.RedisPrefix, key)
87 | return storeSession(ctx, fullKey, data, ttl)
88 | }
89 |
90 | func loadGenericSession(ctx context.Context, key string) (*webauthn.SessionData, error) {
91 | fullKey := fmt.Sprintf("%s:%s", config.RedisPrefix, key)
92 | return loadSession(ctx, fullKey)
93 | }
94 |
--------------------------------------------------------------------------------
/api/version_one/helper/helper.go:
--------------------------------------------------------------------------------
1 | package helper
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "github.com/redis/go-redis/v9"
7 | "github.com/soxft/openid-go/app/model"
8 | "github.com/soxft/openid-go/config"
9 | "github.com/soxft/openid-go/library/apputil"
10 | "github.com/soxft/openid-go/library/toolutil"
11 | "github.com/soxft/openid-go/process/dbutil"
12 | "github.com/soxft/openid-go/process/redisutil"
13 | "gorm.io/gorm"
14 | "log"
15 | )
16 |
17 | // GetUserIdByToken
18 | // 通过Token和appid 获取用户ID
19 | func GetUserIdByToken(ctx context.Context, appId string, token string) (int, error) {
20 | _redis := redisutil.RDB
21 |
22 | userId, err := _redis.Get(ctx, getTokenRedisKey(appId, token)).Int()
23 | if err != nil {
24 | if errors.Is(err, redis.Nil) {
25 | return 0, ErrTokenNotExists
26 | }
27 |
28 | log.Printf("[ERROR] GetUserIdByToken error: %s", err)
29 | return 0, errors.New("server error")
30 | }
31 |
32 | return userId, nil
33 | }
34 |
35 | // GetUserIds
36 | // @description 获取用户ID
37 | func GetUserIds(appId string, userId int) (UserIdsStruct, error) {
38 | openId, err := getUserOpenId(appId, userId)
39 | if err != nil {
40 | return UserIdsStruct{}, err
41 | }
42 | appInfo, err := apputil.GetAppInfo(appId)
43 | if err != nil {
44 | return UserIdsStruct{}, err
45 | }
46 | uniqueId, err := getUserUniqueId(userId, appInfo.AppUserId)
47 | if err != nil {
48 | return UserIdsStruct{}, err
49 | }
50 |
51 | return UserIdsStruct{
52 | OpenId: openId,
53 | UniqueId: uniqueId,
54 | }, nil
55 | }
56 |
57 | func DeleteToken(ctx context.Context, appId string, token string) error {
58 | _redis := redisutil.RDB
59 |
60 | if err := _redis.Del(ctx, getTokenRedisKey(appId, token)).Err(); err != nil {
61 | log.Printf("[ERROR] DeleteToken error: %s", err)
62 | return errors.New("server error")
63 | }
64 | return nil
65 | }
66 |
67 | // GetUserOpenId
68 | // 获取 用户openID
69 | func getUserOpenId(appId string, userId int) (string, error) {
70 | var openId string
71 | err := dbutil.D.Model(&model.OpenId{}).Where(model.OpenId{AppId: appId, UserId: userId}).Select("open_id").First(&openId).Error
72 |
73 | if errors.Is(err, gorm.ErrRecordNotFound) {
74 | return generateOpenId(appId, userId)
75 | } else if err != nil {
76 | log.Printf("[ERROR] GetUserOpenId error: %s", err)
77 | return "", errors.New("server error")
78 | }
79 | return openId, nil
80 | }
81 |
82 | // getUserUniqueId
83 | // 获取用户UniqueId
84 | func getUserUniqueId(userId, DevUserId int) (string, error) {
85 | var uniqueId string
86 | err := dbutil.D.Model(&model.UniqueId{}).Where(model.UniqueId{UserId: userId, DevUserId: DevUserId}).Select("unique_id").First(&uniqueId).Error
87 |
88 | if errors.Is(err, gorm.ErrRecordNotFound) {
89 | return generateUniqueId(userId, DevUserId)
90 | } else if err != nil {
91 | log.Printf("[ERROR] GetUserUniqueId error: %s", err)
92 | return "", errors.New("server error")
93 | }
94 | return uniqueId, nil
95 | }
96 |
97 | func getTokenRedisKey(appId string, token string) string {
98 | return config.RedisPrefix + ":app:" + toolutil.Md5(appId) + ":" + toolutil.Md5(token)
99 | }
100 |
--------------------------------------------------------------------------------
/api/version_one/helper/generate.go:
--------------------------------------------------------------------------------
1 | package helper
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "github.com/soxft/openid-go/app/model"
7 | "github.com/soxft/openid-go/library/toolutil"
8 | "github.com/soxft/openid-go/process/dbutil"
9 | "github.com/soxft/openid-go/process/redisutil"
10 | "gorm.io/gorm"
11 | "log"
12 | "strconv"
13 | "strings"
14 | "time"
15 | )
16 |
17 | // GenerateToken
18 | // @description: v1 获取token (用于跳转redirect_uri携带)
19 | func GenerateToken(ctx context.Context, appId string, userId int) (string, error) {
20 | // check if exists in redis
21 | _redis := redisutil.RDB
22 |
23 | a := toolutil.RandStr(10)
24 | b := toolutil.Md5(time.Now().Format("15:04:05"))[:10]
25 | c := toolutil.Md5(strconv.FormatInt(time.Now().UnixNano(), 10))[:10]
26 |
27 | token := a + "." + b + c + toolutil.RandStr(9)
28 | token = strings.ToLower(token)
29 |
30 | _redisKey := getTokenRedisKey(appId, token)
31 |
32 | if exists, err := _redis.Exists(ctx, _redisKey).Result(); err != nil {
33 | log.Printf("[error] redis.Bool: %s", err.Error())
34 | return "", errors.New("system error")
35 | } else if exists == 0 {
36 | // 不存在 则存入redis 并返回
37 | if _, err := _redis.SetEx(ctx, _redisKey, userId, 3*time.Minute).Result(); err != nil {
38 | log.Printf("[ERROR] GetToken error: %s", err)
39 | return "", errors.New("server error")
40 | }
41 | return token, nil
42 | }
43 | // 存在
44 | return GenerateToken(ctx, appId, userId)
45 | }
46 |
47 | // generateOpenId
48 | // 创建一个唯一的openId
49 | func generateOpenId(appId string, userId int) (string, error) {
50 | a := toolutil.Md5(appId)[:10]
51 | b := toolutil.Md5(strconv.Itoa(userId))[:10]
52 | d := toolutil.Md5(strconv.FormatInt(time.Now().UnixNano(), 10))
53 | c := toolutil.Md5(toolutil.RandStr(16))
54 |
55 | openId := a + "." + b + "." + c + d
56 | openId = strings.ToLower(openId)
57 | err := dbutil.D.Model(&model.OpenId{}).Where(model.OpenId{OpenId: openId}).First(&model.OpenId{}).Error
58 |
59 | if errors.Is(err, gorm.ErrRecordNotFound) {
60 | // 不存在
61 | err := dbutil.D.Create(&model.OpenId{
62 | UserId: userId,
63 | AppId: appId,
64 | OpenId: openId,
65 | }).Error
66 | if err != nil {
67 | log.Printf("[ERROR] generateOpenId error: %s", err)
68 | return "", errors.New("server error")
69 | }
70 | return openId, nil
71 | } else if err != nil {
72 | log.Printf("[ERROR] generateOpenId error: %s", err)
73 | return "", errors.New("server error")
74 | } else {
75 | // 存在
76 | return generateOpenId(appId, userId)
77 | }
78 | }
79 |
80 | // checkOpenIdExists
81 | // 创建一个唯一的uniqueId
82 | func generateUniqueId(userId, devUserId int) (string, error) {
83 | a := toolutil.Md5(strconv.Itoa(devUserId))[:10]
84 | b := toolutil.Md5(strconv.Itoa(userId))[:10]
85 | d := toolutil.Md5(strconv.FormatInt(time.Now().UnixNano(), 10))
86 | c := toolutil.Md5(toolutil.RandStr(16))
87 |
88 | uniqueId := a + "." + b + "." + c + d
89 | uniqueId = strings.ToLower(uniqueId)
90 |
91 | err := dbutil.D.Model(&model.UniqueId{}).Where(model.UniqueId{UniqueId: uniqueId}).First(&model.UniqueId{}).Error
92 | if errors.Is(err, gorm.ErrRecordNotFound) {
93 | // 不存在
94 | err := dbutil.D.Create(&model.UniqueId{
95 | UserId: userId,
96 | DevUserId: devUserId,
97 | UniqueId: uniqueId,
98 | }).Error
99 |
100 | if err != nil {
101 | log.Printf("[ERROR] generateUniqueId error: %s", err)
102 | return "", errors.New("server error")
103 | }
104 | return uniqueId, nil
105 | } else if err != nil {
106 | log.Printf("[ERROR] generateUniqueId error: %s", err)
107 | return "", errors.New("server error")
108 | } else {
109 | // 存在
110 | return generateUniqueId(userId, devUserId)
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/library/passkey/user.go:
--------------------------------------------------------------------------------
1 | package passkey
2 |
3 | import (
4 | "encoding/base64"
5 | "strconv"
6 | "strings"
7 |
8 | "github.com/go-webauthn/webauthn/protocol"
9 | "github.com/go-webauthn/webauthn/webauthn"
10 | "github.com/soxft/openid-go/app/model"
11 | )
12 |
13 | type webAuthnUser struct {
14 | account model.Account
15 | credentials []webauthn.Credential
16 | }
17 |
18 | func newWebAuthnUser(account model.Account, passkeys []model.PassKey) (*webAuthnUser, error) {
19 | credentials, err := convertPasskeys(passkeys)
20 | if err != nil {
21 | return nil, err
22 | }
23 | return &webAuthnUser{
24 | account: account,
25 | credentials: credentials,
26 | }, nil
27 | }
28 |
29 | func (u *webAuthnUser) WebAuthnID() []byte {
30 | return []byte(strconv.Itoa(u.account.ID))
31 | }
32 |
33 | func (u *webAuthnUser) WebAuthnName() string {
34 | if u.account.Username != "" {
35 | return u.account.Username
36 | }
37 | return strconv.Itoa(u.account.ID)
38 | }
39 |
40 | func (u *webAuthnUser) WebAuthnDisplayName() string {
41 | if u.account.Email != "" {
42 | return u.account.Email
43 | }
44 | return u.WebAuthnName()
45 | }
46 |
47 | func (u *webAuthnUser) WebAuthnIcon() string {
48 | return ""
49 | }
50 |
51 | func (u *webAuthnUser) WebAuthnCredentials() []webauthn.Credential {
52 | return u.credentials
53 | }
54 |
55 | func convertPasskeys(passkeys []model.PassKey) ([]webauthn.Credential, error) {
56 | credentials := make([]webauthn.Credential, 0, len(passkeys))
57 | for _, key := range passkeys {
58 | credentialID, err := decodeKey(key.CredentialID)
59 | if err != nil {
60 | return nil, err
61 | }
62 | publicKey, err := decodeKey(key.PublicKey)
63 | if err != nil {
64 | return nil, err
65 | }
66 | var aaguid []byte
67 | if key.AAGUID != "" {
68 | aaguid, err = decodeKey(key.AAGUID)
69 | if err != nil {
70 | return nil, err
71 | }
72 | }
73 |
74 | transports := make([]protocol.AuthenticatorTransport, 0)
75 | if key.Transport != "" {
76 | for _, transport := range strings.Split(key.Transport, ",") {
77 | transport = strings.TrimSpace(transport)
78 | if transport == "" {
79 | continue
80 | }
81 | transports = append(transports, protocol.AuthenticatorTransport(transport))
82 | }
83 | }
84 |
85 | credential := webauthn.Credential{
86 | ID: credentialID,
87 | PublicKey: publicKey,
88 | AttestationType: key.Attestation,
89 | Transport: transports,
90 | Authenticator: webauthn.Authenticator{
91 | AAGUID: aaguid,
92 | SignCount: key.SignCount,
93 | CloneWarning: key.CloneWarning,
94 | },
95 | }
96 | credentials = append(credentials, credential)
97 | }
98 | return credentials, nil
99 | }
100 |
101 | // EncodeKey 编码密钥为 base64 字符串(导出给控制器使用)
102 | func EncodeKey(data []byte) string {
103 | return encodeKey(data)
104 | }
105 |
106 | func encodeKey(data []byte) string {
107 | if len(data) == 0 {
108 | return ""
109 | }
110 | return base64.RawURLEncoding.EncodeToString(data)
111 | }
112 |
113 | func decodeKey(encoded string) ([]byte, error) {
114 | if encoded == "" {
115 | return nil, nil
116 | }
117 | data, err := base64.RawURLEncoding.DecodeString(encoded)
118 | if err != nil {
119 | return nil, err
120 | }
121 | return data, nil
122 | }
123 |
124 | func transportsToString(values []protocol.AuthenticatorTransport) string {
125 | if len(values) == 0 {
126 | return ""
127 | }
128 | items := make([]string, 0, len(values))
129 | for _, v := range values {
130 | items = append(items, string(v))
131 | }
132 | return strings.Join(items, ",")
133 | }
134 |
135 | func SplitTransports(raw string) []string {
136 | if raw == "" {
137 | return nil
138 | }
139 | parts := strings.Split(raw, ",")
140 | result := make([]string, 0, len(parts))
141 | for _, part := range parts {
142 | trimmed := strings.TrimSpace(part)
143 | if trimmed == "" {
144 | continue
145 | }
146 | result = append(result, trimmed)
147 | }
148 | return result
149 | }
150 |
--------------------------------------------------------------------------------
/library/passkey/storage.go:
--------------------------------------------------------------------------------
1 | package passkey
2 |
3 | import (
4 | "errors"
5 | "time"
6 |
7 | "github.com/go-webauthn/webauthn/webauthn"
8 | "github.com/soxft/openid-go/app/model"
9 | "github.com/soxft/openid-go/process/dbutil"
10 | "gorm.io/gorm"
11 | )
12 |
13 | func loadUserPasskeys(userID int) ([]model.PassKey, error) {
14 | var passkeys []model.PassKey
15 | if err := dbutil.D.Where("user_id = ?", userID).Find(&passkeys).Error; err != nil {
16 | return nil, err
17 | }
18 | return passkeys, nil
19 | }
20 |
21 | func saveCredential(userID int, credential *webauthn.Credential) (*model.PassKey, error) {
22 | return saveCredentialWithRemark(userID, credential, "")
23 | }
24 |
25 | func saveCredentialWithRemark(userID int, credential *webauthn.Credential, remark string) (*model.PassKey, error) {
26 | if credential == nil {
27 | return nil, errors.New("credential is nil")
28 | }
29 |
30 | encodedID := encodeKey(credential.ID)
31 | if encodedID == "" {
32 | return nil, errors.New("credential id is empty")
33 | }
34 |
35 | encodedPK := encodeKey(credential.PublicKey)
36 | encodedAAGUID := encodeKey(credential.Authenticator.AAGUID)
37 | transports := transportsToString(credential.Transport)
38 |
39 | now := time.Now().Unix()
40 | passkey := model.PassKey{
41 | UserID: userID,
42 | CredentialID: encodedID,
43 | PublicKey: encodedPK,
44 | Attestation: credential.AttestationType,
45 | AAGUID: encodedAAGUID,
46 | SignCount: credential.Authenticator.SignCount,
47 | Transport: transports,
48 | CloneWarning: credential.Authenticator.CloneWarning,
49 | Remark: remark, // 添加备注
50 | CreatedAt: now,
51 | UpdatedAt: now,
52 | }
53 |
54 | var existing model.PassKey
55 | err := dbutil.D.Where("user_id = ? AND credential_id = ?", userID, encodedID).Take(&existing).Error
56 | switch {
57 | case errors.Is(err, gorm.ErrRecordNotFound):
58 | if err := dbutil.D.Create(&passkey).Error; err != nil {
59 | return nil, err
60 | }
61 | return &passkey, nil
62 | case err != nil:
63 | return nil, err
64 | default:
65 | updates := map[string]interface{}{
66 | "public_key": encodedPK,
67 | "attestation": credential.AttestationType,
68 | "aaguid": encodedAAGUID,
69 | "sign_count": credential.Authenticator.SignCount,
70 | "transport": transports,
71 | "clone_warning": credential.Authenticator.CloneWarning,
72 | "updated_at": time.Now().Unix(),
73 | }
74 | if err := dbutil.D.Model(&existing).Updates(updates).Error; err != nil {
75 | return nil, err
76 | }
77 | if err := dbutil.D.Where("id = ?", existing.ID).Take(&existing).Error; err != nil {
78 | return nil, err
79 | }
80 | return &existing, nil
81 | }
82 | }
83 |
84 | func updateCredentialAfterLogin(userID int, credential *webauthn.Credential) (*model.PassKey, error) {
85 | encodedID := encodeKey(credential.ID)
86 | if encodedID == "" {
87 | return nil, errors.New("credential id is empty")
88 | }
89 |
90 | now := time.Now().Unix()
91 | updates := map[string]interface{}{
92 | "sign_count": credential.Authenticator.SignCount,
93 | "clone_warning": credential.Authenticator.CloneWarning,
94 | "transport": transportsToString(credential.Transport),
95 | "last_used_at": now,
96 | "updated_at": now,
97 | }
98 |
99 | if err := dbutil.D.Model(&model.PassKey{}).
100 | Where("user_id = ? AND credential_id = ?", userID, encodedID).
101 | Updates(updates).Error; err != nil {
102 | return nil, err
103 | }
104 |
105 | var result model.PassKey
106 | if err := dbutil.D.Where("user_id = ? AND credential_id = ?", userID, encodedID).Take(&result).Error; err != nil {
107 | return nil, err
108 | }
109 | return &result, nil
110 | }
111 |
112 | func removeCredential(userID, passkeyID int) error {
113 | res := dbutil.D.Where("user_id = ? AND id = ?", userID, passkeyID).Delete(&model.PassKey{})
114 | if res.Error != nil {
115 | return res.Error
116 | }
117 | if res.RowsAffected == 0 {
118 | return gorm.ErrRecordNotFound
119 | }
120 | return nil
121 | }
122 |
--------------------------------------------------------------------------------
/app/controller/forget.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "log"
7 | "time"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/soxft/openid-go/app/dto"
11 | "github.com/soxft/openid-go/app/model"
12 | "github.com/soxft/openid-go/library/apiutil"
13 | "github.com/soxft/openid-go/library/codeutil"
14 | "github.com/soxft/openid-go/library/mailutil"
15 | "github.com/soxft/openid-go/library/toolutil"
16 | "github.com/soxft/openid-go/library/userutil"
17 | "github.com/soxft/openid-go/process/dbutil"
18 | "github.com/soxft/openid-go/process/queueutil"
19 | "gorm.io/gorm"
20 | )
21 |
22 | // ForgetPasswordCode
23 | // @description 忘记密码发送邮件
24 | // @router POST /forget/password/code
25 | func ForgetPasswordCode(c *gin.Context) {
26 | var req dto.ForgetPasswordCodeRequest
27 | api := apiutil.New(c)
28 |
29 | if err := dto.BindJSON(c, &req); err != nil {
30 | api.Fail("请求参数错误")
31 | return
32 | }
33 |
34 | email := req.Email
35 |
36 | if !toolutil.IsEmail(email) {
37 | api.Fail("非法的邮箱格式")
38 | return
39 | }
40 | if exists, err := userutil.CheckEmailExists(email); err != nil {
41 | api.Fail("server error")
42 | return
43 | } else if !exists {
44 | api.Fail("邮箱不存在")
45 | return
46 | }
47 |
48 | // 防止频繁发送验证码
49 | if beacon, err := mailutil.CheckBeacon(c, email); beacon || err != nil {
50 | api.Fail("code send too frequently")
51 | return
52 | }
53 |
54 | // send mail
55 | coder := codeutil.New(c)
56 | verifyCode := coder.Create(6)
57 | _msg, _ := json.Marshal(mailutil.Mail{
58 | ToAddress: email,
59 | Subject: verifyCode + " 为您的验证码",
60 | Content: "您正在申请找回密码, 您的验证码为: " + verifyCode + ", 有效期10分钟",
61 | Typ: "forgetPwd",
62 | })
63 |
64 | if err := coder.Save("forgetPwd", email, verifyCode, 60*time.Minute); err != nil {
65 | api.Fail("send code failed")
66 | return
67 | }
68 | if err := queueutil.Q.Publish("mail", string(_msg), 0); err != nil {
69 | coder.Consume("forgetPwd", email) // 删除code
70 | api.Fail("send code failed")
71 | return
72 | }
73 | _ = mailutil.CreateBeacon(c, email, 120)
74 |
75 | api.Success("success")
76 | }
77 |
78 | // ForgetPasswordUpdate
79 | // @description 忘记密码重置
80 | // @router PATCH /forget/password/update
81 | func ForgetPasswordUpdate(c *gin.Context) {
82 | var req dto.ForgetPasswordUpdateRequest
83 | api := apiutil.New(c)
84 |
85 | if err := dto.BindJSON(c, &req); err != nil {
86 | api.Fail("请求参数错误")
87 | return
88 | }
89 |
90 | email := req.Email
91 | code := req.Code
92 | newPassword := req.Password
93 |
94 | if !toolutil.IsEmail(email) {
95 | api.Fail("非法的邮箱格式")
96 | return
97 | }
98 |
99 | if !toolutil.IsPassword(newPassword) {
100 | api.Fail("密码应在8-64位之间")
101 | return
102 | }
103 |
104 | // verify code
105 | coder := codeutil.New(c)
106 | if pass, err := coder.Check("forgetPwd", email, code); !pass || err != nil {
107 | api.Fail("验证码错误或已过期")
108 | return
109 | }
110 |
111 | // update password
112 | var err error
113 | var pwdDb string
114 | if pwdDb, err = userutil.GeneratePwd(newPassword); err != nil {
115 | log.Println(err)
116 | api.Fail("pwd generate error")
117 | return
118 | }
119 |
120 | // get Username by email
121 | var username string
122 | err = dbutil.D.Model(model.Account{}).Where(model.Account{Email: email}).Select("username").Take(&username).Error
123 | if errors.Is(err, gorm.ErrRecordNotFound) {
124 | // 系统中不存在该邮箱
125 | api.Fail("验证码错误或已过期")
126 | return
127 | } else if err != nil {
128 | log.Printf("[ERROR] GetUsername SQL: %s", err)
129 | api.Fail("system error")
130 | return
131 | }
132 |
133 | err = dbutil.D.Model(model.Account{}).Where(model.Account{Email: email}).Updates(&model.Account{Password: pwdDb}).Error
134 | if err != nil {
135 | log.Printf("[ERROR] UserPasswordUpdate %v", err)
136 | api.Fail("system error")
137 | return
138 | }
139 | coder.Consume("forgetPwd", email)
140 |
141 | // 修改密码后续安全操作
142 | _ = userutil.SetJwtExpire(c, c.GetString("token"))
143 | userutil.PasswordChangeNotify(email, time.Now())
144 |
145 | api.Success("修改成功!")
146 | }
147 |
--------------------------------------------------------------------------------
/library/userutil/user.go:
--------------------------------------------------------------------------------
1 | package userutil
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "github.com/soxft/openid-go/app/model"
7 | "github.com/soxft/openid-go/library/mailutil"
8 | "github.com/soxft/openid-go/library/toolutil"
9 | "github.com/soxft/openid-go/process/dbutil"
10 | "github.com/soxft/openid-go/process/queueutil"
11 | "gorm.io/gorm"
12 | "log"
13 | "time"
14 | )
15 |
16 | // GenerateSalt
17 | // @description Generate a random salt
18 | //func GenerateSalt() string {
19 | // str := toolutil.RandStr(16)
20 | // timestamp := time.Now().Unix()
21 | // return toolutil.Md5(str + strconv.FormatInt(timestamp, 10))
22 | //}
23 |
24 | // RegisterCheck
25 | // @description Check users email or user if already exists
26 | func RegisterCheck(username, email string) error {
27 | if exists, err := CheckUserNameExists(username); err != nil {
28 | return err
29 | } else if exists {
30 | return ErrUsernameExists
31 | }
32 |
33 | if exists, err := CheckEmailExists(email); err != nil {
34 | return err
35 | } else if exists {
36 | return ErrEmailExists
37 | }
38 |
39 | return nil
40 | }
41 |
42 | // CheckUserNameExists
43 | // @description Check username if exists in database
44 | func CheckUserNameExists(username string) (bool, error) {
45 | var ID int64
46 | err := dbutil.D.Model(&model.Account{}).Select("id").Where(model.Account{Username: username}).First(&ID).Error
47 | if errors.Is(err, gorm.ErrRecordNotFound) {
48 | return false, nil
49 | } else if err != nil {
50 | log.Printf("[ERROR] CheckUserNameExists: %s", err.Error())
51 | return false, errors.New("system error")
52 | }
53 | return true, nil
54 | }
55 |
56 | // CheckEmailExists
57 | // @description Check email if exists in database
58 | func CheckEmailExists(email string) (bool, error) {
59 | var ID string
60 | err := dbutil.D.Model(&model.Account{}).Select("id").Where(&model.Account{Email: email}).Take(&ID).Error
61 | if errors.Is(err, gorm.ErrRecordNotFound) {
62 | return false, nil
63 | } else if err != nil {
64 | log.Printf("[ERROR] CheckUserNameExists: %s", err.Error())
65 | return false, errors.New("system error")
66 | }
67 | return true, nil
68 | }
69 |
70 | // CheckPassword
71 | // @description 验证用户登录
72 | // if return < 0 error, pwd error or server error
73 | // if return > 0 success, return user id
74 | func CheckPassword(username, password string) (int, error) {
75 | var err error
76 | var account model.Account
77 |
78 | if toolutil.IsEmail(username) {
79 | err = dbutil.D.Select("id, password").Where(model.Account{Email: username}).Take(&account).Error
80 | } else {
81 | err = dbutil.D.Select("id, password").Where(model.Account{Username: username}).Take(&account).Error
82 | }
83 |
84 | if errors.Is(err, gorm.ErrRecordNotFound) {
85 | return 0, ErrPasswd
86 | } else if err != nil {
87 | log.Printf("[ERROR] CheckPassword: %v", err)
88 | return 0, ErrDatabase
89 | }
90 | if CheckPwd(password, account.Password) != nil {
91 | return 0, ErrPasswd
92 | }
93 | return account.ID, nil
94 | }
95 |
96 | // CheckPasswordByUserId
97 | // @description 通过userid验证用户password
98 | //func CheckPasswordByUserId(userId int, password string) (bool, error) {
99 | // // rewrite by gorm
100 | // var account model.Account
101 | //
102 | // if err := dbutil.D.Model(model.Account{}).Select("id, salt, password").
103 | // Where(model.Account{ID: userId}).Take(&account).Error; errors.Is(err, gorm.ErrRecordNotFound) {
104 | // return false, nil
105 | // } else if err != nil {
106 | // return false, errors.New("system error")
107 | // }
108 | // if CheckPwd(password, account.Password) != nil {
109 | // return false, nil
110 | // }
111 | // return true, nil
112 | //}
113 |
114 | func PasswordChangeNotify(email string, timestamp time.Time) {
115 | _msg, _ := json.Marshal(mailutil.Mail{
116 | ToAddress: email,
117 | Subject: "您的密码已修改",
118 | Content: "您的密码已于" + timestamp.Format("2006-01-02 15:04:05") + "修改, 如果不是您本人操作, 请及时联系管理员",
119 | Typ: "passwordChangeNotify",
120 | })
121 | _ = queueutil.Q.Publish("mail", string(_msg), 5)
122 | }
123 |
124 | func EmailChangeNotify(email string, timestamp time.Time) {
125 | _msg, _ := json.Marshal(mailutil.Mail{
126 | ToAddress: email,
127 | Subject: "您的邮箱已修改",
128 | Content: "您的邮箱已于" + timestamp.Format("2006-01-02 15:04:05") + "修改, 如果不是您本人操作, 请及时联系管理员",
129 | Typ: "emailChangeNotify",
130 | })
131 | _ = queueutil.Q.Publish("mail", string(_msg), 5)
132 | }
133 |
--------------------------------------------------------------------------------
/library/userutil/jwt.go:
--------------------------------------------------------------------------------
1 | package userutil
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "github.com/golang-jwt/jwt/v4"
8 | "github.com/soxft/openid-go/app/model"
9 | "github.com/soxft/openid-go/config"
10 | "github.com/soxft/openid-go/library/toolutil"
11 | "github.com/soxft/openid-go/process/dbutil"
12 | "github.com/soxft/openid-go/process/redisutil"
13 | "gorm.io/gorm"
14 | "log"
15 | "regexp"
16 | "time"
17 | )
18 |
19 | // GetJwtFromAuth
20 | // 从 Authorization 中获取JWT
21 | func GetJwtFromAuth(Authorization string) string {
22 | reg, _ := regexp.Compile(`^Bearer\s+(.*)$`)
23 | if reg.MatchString(Authorization) {
24 | return reg.FindStringSubmatch(Authorization)[1]
25 | }
26 | return ""
27 | }
28 |
29 | // GenerateJwt
30 | // @description generate JWT token for user
31 | func GenerateJwt(userId int, clientIp string) (string, error) {
32 | var userInfo model.Account
33 | err := dbutil.D.Model(model.Account{}).Select("id, username, email, last_time, last_ip").Where(model.Account{ID: userId}).Take(&userInfo).Error
34 | if errors.Is(err, gorm.ErrRecordNotFound) {
35 | return "", nil
36 | } else if err != nil {
37 | return "", err
38 | }
39 |
40 | timeNow := time.Now().Unix()
41 |
42 | uInfo := UserInfo{
43 | UserId: userId,
44 | Username: userInfo.Username,
45 | Email: userInfo.Email,
46 | LastTime: timeNow,
47 | }
48 | // update last login info
49 | setUserLastLogin(userInfo.ID, timeNow, clientIp)
50 |
51 | return jwt.NewWithClaims(jwt.SigningMethodHS512, JwtClaims{
52 | ID: generateJti(uInfo),
53 | ExpireAt: time.Now().Add(24 * 30 * time.Hour).Unix(),
54 | IssuedAt: time.Now().Unix(),
55 | Issuer: config.Server.Title,
56 | Username: userInfo.Username,
57 | UserId: userId,
58 | Email: userInfo.Email,
59 | LastTime: timeNow,
60 | }).SignedString([]byte(config.Jwt.Secret))
61 | }
62 |
63 | // CheckPermission
64 | // @description check user permission
65 | func CheckPermission(ctx context.Context, _jwt string) (UserInfo, error) {
66 | JwtClaims, err := JwtDecode(_jwt)
67 | if err != nil {
68 | return UserInfo{}, err
69 | }
70 | if checkJti(ctx, JwtClaims.ID) != nil {
71 | return UserInfo{}, ErrJwtExpired
72 | }
73 | return UserInfo{
74 | UserId: JwtClaims.UserId,
75 | Username: JwtClaims.Username,
76 | Email: JwtClaims.Email,
77 | LastTime: JwtClaims.LastTime,
78 | }, nil
79 | }
80 |
81 | // JwtDecode
82 | // @description check JWT token
83 | func JwtDecode(_jwt string) (JwtClaims, error) {
84 | token, err := jwt.ParseWithClaims(_jwt, &JwtClaims{}, func(token *jwt.Token) (interface{}, error) {
85 | return []byte(config.Jwt.Secret), nil
86 | })
87 | if err != nil {
88 | return JwtClaims{}, err
89 | }
90 |
91 | if claims, ok := token.Claims.(*JwtClaims); ok && token.Valid {
92 | return *claims, nil
93 | }
94 |
95 | return JwtClaims{}, errors.New("jwt token error")
96 | }
97 |
98 | // SetJwtExpire
99 | // @description 标记JWT过期
100 | func SetJwtExpire(c context.Context, _jwt string) error {
101 | JwtClaims, _ := JwtDecode(_jwt)
102 | _redis := redisutil.RDB
103 |
104 | ttl := JwtClaims.ExpireAt - time.Now().Unix()
105 |
106 | err := _redis.SetEx(c, getJwtExpiredKey(JwtClaims.ID), "1", time.Duration(ttl)).Err()
107 | if err != nil {
108 | log.Printf("[ERROR] SetJwtExpire: %s", err.Error())
109 | return errors.New("set jwt expire error")
110 | }
111 |
112 | return nil
113 | }
114 |
115 | // checkJti
116 | func checkJti(ctx context.Context, jti string) error {
117 | _redis := redisutil.RDB
118 |
119 | expired, err := _redis.Exists(ctx, getJwtExpiredKey(jti)).Result()
120 | if err != nil {
121 | log.Printf("[ERROR] checkJti: %s", err.Error())
122 | return errors.New("check jti error")
123 | }
124 | if expired == 1 {
125 | return ErrJwtExpired
126 | }
127 |
128 | return nil
129 | }
130 |
131 | // generateJti 创建 Jti
132 | func generateJti(user UserInfo) string {
133 | JtiJson, _ := json.Marshal(map[string]string{
134 | "username": user.Username,
135 | "randStr": toolutil.RandStr(32),
136 | "time": time.Now().Format("150405"),
137 | })
138 | _jti := toolutil.Md5(string(JtiJson))
139 | return _jti
140 | }
141 |
142 | func setUserLastLogin(userId int, lastTime int64, lastIp string) {
143 | dbutil.D.Model(&model.Account{}).Where(model.Account{ID: userId}).Updates(&model.Account{LastTime: lastTime, LastIp: lastIp})
144 | }
145 |
146 | // getJwtExpiredKey
147 | func getJwtExpiredKey(jti string) string {
148 | return config.RedisPrefix + ":jti:expired:" + jti
149 | }
150 |
--------------------------------------------------------------------------------
/app/controller/register.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "github.com/gin-gonic/gin"
7 | "github.com/soxft/openid-go/app/dto"
8 | "github.com/soxft/openid-go/app/model"
9 | "github.com/soxft/openid-go/config"
10 | "github.com/soxft/openid-go/library/apiutil"
11 | "github.com/soxft/openid-go/library/codeutil"
12 | "github.com/soxft/openid-go/library/mailutil"
13 | "github.com/soxft/openid-go/library/toolutil"
14 | "github.com/soxft/openid-go/library/userutil"
15 | "github.com/soxft/openid-go/process/dbutil"
16 | "github.com/soxft/openid-go/process/queueutil"
17 | "log"
18 | "time"
19 | )
20 |
21 | // RegisterCode
22 | // @description send code to email
23 | // @route POST /register/code
24 | func RegisterCode(c *gin.Context) {
25 | var req dto.RegisterCodeRequest
26 | api := apiutil.New(c)
27 |
28 | if err := dto.BindJSON(c, &req); err != nil {
29 | api.Fail("请求参数错误")
30 | return
31 | }
32 |
33 | email := req.Email
34 | // verify email by re
35 | if !toolutil.IsEmail(email) {
36 | api.Fail("invalid email")
37 | return
38 | }
39 |
40 | // 防止频繁发送验证码
41 | if beacon, err := mailutil.CheckBeacon(c, email); beacon || err != nil {
42 | api.Fail("code send too frequently")
43 | return
44 | }
45 |
46 | // 先创建 beacon 再说
47 | _ = mailutil.CreateBeacon(c, email, 120)
48 |
49 | // check mail exists
50 | if exists, err := userutil.CheckEmailExists(email); err != nil {
51 | go mailutil.DeleteBeacon(c, email) // 删除信标
52 |
53 | api.Fail("server error")
54 | return
55 | } else if exists {
56 | go mailutil.DeleteBeacon(c, email) // 删除信标
57 |
58 | api.Fail("email already exists")
59 | return
60 | }
61 |
62 | // send Code
63 | coder := codeutil.New(c)
64 | verifyCode := coder.Create(4)
65 |
66 | _msg, _ := json.Marshal(mailutil.Mail{
67 | ToAddress: email,
68 | Subject: verifyCode + " 为您的验证码",
69 | Content: "您正在注册 " + config.Server.Title + ". 您的验证码为: " + verifyCode + ", 有效期10分钟.",
70 | Typ: "register",
71 | })
72 |
73 | if err := coder.Save("register", email, verifyCode, 60*time.Minute); err != nil {
74 | go mailutil.DeleteBeacon(c, email) // 删除信标
75 |
76 | api.Fail("send code failed")
77 | return
78 | }
79 |
80 | if err := queueutil.Q.Publish("mail", string(_msg), 0); err != nil {
81 | go coder.Consume("register", email) // 删除code
82 | go mailutil.DeleteBeacon(c, email) // 删除信标
83 |
84 | api.Fail("send code failed")
85 | return
86 | }
87 |
88 | api.Success("code send success")
89 | }
90 |
91 | // RegisterSubmit
92 | // @description do register
93 | // @route POST /register
94 | func RegisterSubmit(c *gin.Context) {
95 | var req dto.RegisterSubmitRequest
96 | api := apiutil.New(c)
97 |
98 | if err := dto.BindJSON(c, &req); err != nil {
99 | api.Fail("请求参数错误")
100 | return
101 | }
102 |
103 | email := req.Email
104 | verifyCode := req.Code
105 | username := req.Username
106 | password := req.Password
107 | // 合法检测
108 | if !toolutil.IsEmail(email) {
109 | api.Fail("非法的邮箱")
110 | return
111 | }
112 | if !toolutil.IsUserName(username) {
113 | api.Fail("非法的用户名")
114 | return
115 | }
116 | if !toolutil.IsPassword(password) {
117 | api.Fail("密码应在8~64位")
118 | return
119 | }
120 |
121 | // 验证码检测
122 | coder := codeutil.New(c)
123 | if pass, err := coder.Check("register", email, verifyCode); !pass || err != nil {
124 | api.Fail("invalid code")
125 | return
126 | }
127 |
128 | // 重复检测
129 | if err := userutil.RegisterCheck(username, email); err != nil {
130 | if errors.Is(err, userutil.ErrUsernameExists) {
131 | api.Fail("用户名已存在")
132 | return
133 | } else if errors.Is(err, userutil.ErrEmailExists) {
134 | api.Fail("邮箱已存在")
135 | return
136 | }
137 | api.Fail("server error")
138 | return
139 | }
140 |
141 | // 创建用户
142 | userIp := c.ClientIP()
143 | timestamp := time.Now().Unix()
144 |
145 | var err error
146 | var pwd string
147 | if pwd, err = userutil.GeneratePwd(password); err != nil {
148 | log.Println(err)
149 | api.Fail("pwd generate error")
150 | return
151 | }
152 |
153 | // insert to Database
154 | newUser := model.Account{
155 | Username: username,
156 | Password: pwd,
157 | Email: email,
158 | RegTime: timestamp,
159 | RegIp: userIp,
160 | LastTime: timestamp,
161 | LastIp: userIp,
162 | }
163 | result := dbutil.D.Create(&newUser)
164 | if result.Error != nil || result.RowsAffected == 0 {
165 | log.Printf("[ERROR] RegisterSubmit %s", result.Error.Error())
166 | api.Fail("register failed")
167 | return
168 | }
169 |
170 | // 消费验证码
171 | coder.Consume("register", email)
172 | api.Success("success")
173 | }
174 |
--------------------------------------------------------------------------------
/docs/passkey-api.md:
--------------------------------------------------------------------------------
1 | # Passkey 前端接入指南
2 |
3 | 本文档面向浏览器/前端开发者,说明如何串联后端 Passkey API 完成注册、登录与管理流程。
4 |
5 | ## 基础信息
6 |
7 | - 所有接口前缀:`/passkey`
8 | - 鉴权方式:需要现有账号已登录,并在请求头附带 `Authorization: Bearer `
9 | - 统一响应结构:
10 |
11 | ```json
12 | {
13 | "success": true,
14 | "message": "success",
15 | "data": {
16 | "...": "..."
17 | }
18 | }
19 | ```
20 |
21 | 当 `success=false` 时,`message` 字段直接给出错误提示,可据此进行前端展示。
22 |
23 | ## 常用工具函数
24 |
25 | 浏览器 WebAuthn API 会返回 `ArrayBuffer`,需要转换成 base64url 字符串后再发送给后端。可参考:
26 |
27 | ```ts
28 | const toBase64Url = (buffer: ArrayBuffer): string => {
29 | const bytes = new Uint8Array(buffer);
30 | let binary = "";
31 | bytes.forEach(b => (binary += String.fromCharCode(b)));
32 | return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
33 | };
34 |
35 | const publicKeyCredentialToJSON = (cred: PublicKeyCredential) => ({
36 | id: cred.id,
37 | rawId: toBase64Url(cred.rawId),
38 | type: cred.type,
39 | clientExtensionResults: cred.getClientExtensionResults(),
40 | response: {
41 | attestationObject: cred.response.attestationObject
42 | ? toBase64Url((cred.response as AuthenticatorAttestationResponse).attestationObject)
43 | : undefined,
44 | clientDataJSON: toBase64Url(cred.response.clientDataJSON),
45 | authenticatorData: cred.response.authenticatorData
46 | ? toBase64Url((cred.response as AuthenticatorAssertionResponse).authenticatorData)
47 | : undefined,
48 | signature: cred.response.signature
49 | ? toBase64Url((cred.response as AuthenticatorAssertionResponse).signature)
50 | : undefined,
51 | userHandle: cred.response.userHandle
52 | ? toBase64Url((cred.response as AuthenticatorAssertionResponse).userHandle!)
53 | : undefined,
54 | },
55 | });
56 | ```
57 |
58 | 后文所有 POST 请求体均指向此 JSON 结构。
59 |
60 | ## 注册流程
61 |
62 | `navigator.credentials.create()` 生成的 `PublicKeyCredential` 结果需完整序列化后发送到后端。
63 |
64 | - **获取注册参数**
65 | - `GET /passkey/register/options`
66 | - 成功 `data`:WebAuthn `PublicKeyCredentialCreationOptions`,直接作为浏览器注册调用的参数。
67 |
68 | ```ts
69 | const { data } = await fetchJson("/passkey/register/options");
70 | const credential = await navigator.credentials.create({ publicKey: data });
71 | ```
72 |
73 | - **提交注册结果**
74 | - `POST /passkey/register`
75 | - `Content-Type: application/json`
76 | - 请求体:`PublicKeyCredential`(`navigator.credentials.create` 的完整 JSON,包含 `id`、`rawId`、`response` 等字段)。
77 | - 成功 `data`: `{ "passkeyId": number }`,表示新绑定的 Passkey 记录 ID。
78 |
79 | ```ts
80 | const credJSON = publicKeyCredentialToJSON(credential as PublicKeyCredential);
81 | const { data } = await fetchJson("/passkey/register", {
82 | method: "POST",
83 | body: JSON.stringify(credJSON),
84 | });
85 | console.log("new passkey id", data.passkeyId);
86 | ```
87 |
88 | 常见失败响应:
89 | - `挑战已过期,请重试`:Redis 中的注册会话过期,需要重新获取 options。
90 | - `注册失败`:注册数据校验错误或数据库写入失败。
91 |
92 | ## 登录流程
93 |
94 | `navigator.credentials.get()` 生成的 `PublicKeyCredential` 结果需完整序列化后发送到后端。
95 |
96 | - **获取登录参数**
97 | - `GET /passkey/login/options`
98 | - 成功 `data`:WebAuthn `PublicKeyCredentialRequestOptions`。
99 | - 若尚未绑定 Passkey,返回 `success=false`,`message=未绑定 Passkey`。
100 |
101 | ```ts
102 | const { data } = await fetchJson("/passkey/login/options");
103 | const credential = await navigator.credentials.get({ publicKey: data });
104 | ```
105 |
106 | - **提交登录结果**
107 | - `POST /passkey/login`
108 | - `Content-Type: application/json`
109 | - 请求体:`PublicKeyCredential`(`navigator.credentials.get` 的完整 JSON)。
110 | - 成功 `data`: `{ "token": string, "passkeyId": number }`。
111 | - `token` 为新的 JWT,建议前端覆盖旧登录态。
112 |
113 | ```ts
114 | const credJSON = publicKeyCredentialToJSON(credential as PublicKeyCredential);
115 | const { data } = await fetchJson("/passkey/login", {
116 | method: "POST",
117 | body: JSON.stringify(credJSON),
118 | });
119 | updateToken(data.token);
120 | ```
121 |
122 | 常见失败响应:
123 | - `挑战已过期,请重试`:Redis 中的登录会话失效,需要重新获取 options。
124 | - `未绑定 Passkey`:用户无可用凭证。
125 | - `登录失败`:签名校验失败或服务器异常。
126 |
127 | ## Passkey 管理
128 |
129 | - **列表 Passkey**
130 | - `GET /passkey`
131 | - 成功 `data`: `{ "items": PasskeySummary[] }`
132 | - `PasskeySummary` 字段:
133 | - `id`: 记录 ID
134 | - `createdAt`: 创建时间
135 | - `lastUsedAt`: 最近使用时间(可能为 `null`)
136 | - `cloneWarning`: WebAuthn Clone Warning 标记
137 | - `signCount`: 签名计数
138 | - `transports`: 可用传输方式字符串数组
139 |
140 | - **删除 Passkey**
141 | - `DELETE /passkey/:id`
142 | - 路径参数 `id`:待删除记录的整数 ID。
143 | - 成功 `message`: `success`。
144 | - 若 ID 不存在,返回 `success=false`,`message=Passkey 不存在`。
145 |
146 | ## 前端调用提示
147 |
148 | - 建议直接使用 WebAuthn API 的 `publicKey` 参数与响应,避免二次加工导致字段缺失。
149 | - `rawId`、`response.attestationObject`、`response.clientDataJSON` 等字段需要 base64url 编码;浏览器返回的 `ArrayBuffer` 需自行转换。
150 | - 操作超时或页面刷新会导致会话丢失,需重新调用 `GET /passkey/.../options`。
151 | - 若遇到 `未绑定 Passkey`,可提示用户先走注册流程再重试登录。
152 | - 在同一页面内重复使用 `navigator.credentials.*` 前建议捕获 `AbortError`、`NotAllowedError` 并给出友好提示。
153 |
--------------------------------------------------------------------------------
/app/controller/app.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "log"
7 | "strconv"
8 | "strings"
9 |
10 | "github.com/gin-gonic/gin"
11 | "github.com/soxft/openid-go/app/dto"
12 | "github.com/soxft/openid-go/app/model"
13 | "github.com/soxft/openid-go/library/apiutil"
14 | "github.com/soxft/openid-go/library/apputil"
15 | "github.com/soxft/openid-go/process/dbutil"
16 | )
17 |
18 | // AppCreate
19 | // @description: 创建应用
20 | // @route: POST /app/create
21 | func AppCreate(c *gin.Context) {
22 | var req dto.AppCreateRequest
23 | api := apiutil.New(c)
24 |
25 | if err := dto.BindJSON(c, &req); err != nil {
26 | api.Fail("请求参数错误")
27 | return
28 | }
29 |
30 | if !apputil.CheckName(req.AppName) {
31 | api.Fail("应用名称不合法")
32 | return
33 | }
34 | // 创建应用
35 | if success, err := apputil.CreateApp(c.GetInt("userId"), req.AppName); !success {
36 | api.Fail(err.Error())
37 | return
38 | }
39 |
40 | api.Success("创建应用成功")
41 | }
42 |
43 | // AppEdit
44 | // @description: 编辑应用
45 | // @route: PUT /app/:id
46 | func AppEdit(c *gin.Context) {
47 | var req dto.AppEditRequest
48 | appId := c.Param("appid")
49 | api := apiutil.New(c)
50 |
51 | if err := dto.BindJSON(c, &req); err != nil {
52 | api.Fail("请求参数错误")
53 | return
54 | }
55 |
56 | // 参数合法性检测
57 | if !apputil.CheckName(req.AppName) {
58 | api.Fail("应用名称不合法")
59 | return
60 | }
61 |
62 | if len(req.AppGateway) == 0 {
63 | api.Fail("网关不能为空")
64 | return
65 | } else if len(req.AppGateway) > 200 {
66 | api.Fail("网关长度不能超过 200字符")
67 | return
68 | }
69 |
70 | var gateWayCount int
71 | // 检测网关是否合法
72 | var gateways []string
73 |
74 | for _, gateway := range strings.Split(req.AppGateway, "\n") {
75 | gateway = strings.TrimSpace(gateway)
76 | if gateway == "" {
77 | continue
78 | }
79 | if !apputil.CheckGateway(gateway) {
80 | apiutil.New(c).Fail(fmt.Sprintf("网关 %s 不合法", gateway))
81 | return
82 | }
83 | gateways = append(gateways, gateway)
84 |
85 | if gateWayCount++; gateWayCount > 10 {
86 | api.Fail("网关数量不能超过 10 个")
87 | return
88 | }
89 | }
90 |
91 | // Do Update
92 | err := dbutil.D.Model(model.App{}).
93 | Where(model.App{
94 | AppId: appId,
95 | }).
96 | Updates(model.App{
97 | AppName: req.AppName,
98 | AppGateway: strings.Join(gateways, ","),
99 | }).Error
100 | if err != nil {
101 | log.Printf("[ERROR] db.Exec err: %v", err)
102 | api.Fail("system error")
103 | return
104 | }
105 | api.Success("修改成功")
106 | }
107 |
108 | // AppDel
109 | // @description: 删除app
110 | // @route: DELETE /app/:id
111 | func AppDel(c *gin.Context) {
112 | appId := c.Param("appid")
113 | api := apiutil.New(c)
114 |
115 | // delete
116 | if success, err := apputil.DeleteUserApp(appId); !success {
117 | api.Fail(err.Error())
118 | } else if err != nil {
119 | api.Fail("system error")
120 | } else {
121 | api.Success("删除成功")
122 | }
123 | }
124 |
125 | // AppReGenerateSecret
126 | // @description: 重新生成secret
127 | func AppReGenerateSecret(c *gin.Context) {
128 | appId := c.Param("appid")
129 |
130 | api := apiutil.New(c)
131 |
132 | // re generate secret
133 | if newToken, err := apputil.ReGenerateSecret(appId); err != nil {
134 | log.Printf("[ERROR] ReGenerateSecret error: %s", err)
135 | api.Fail("re generate secret failed, try again later")
136 | } else {
137 | api.SuccessWithData("重置 AppSecret 成功!", gin.H{
138 | "secret": newToken,
139 | })
140 | }
141 | }
142 |
143 | // AppInfo
144 | // @description: 获取app详细信息
145 | // GET /app/:id
146 | func AppInfo(c *gin.Context) {
147 | appId := c.Param("appid")
148 | api := apiutil.New(c)
149 |
150 | // get app info
151 | if appInfo, err := apputil.GetAppInfo(appId); err != nil {
152 | if errors.Is(err, apputil.ErrAppNotExist) {
153 | api.Fail("应用不存在")
154 | return
155 | }
156 | api.Fail("system error")
157 | return
158 | } else {
159 | appInfo.AppGateway = strings.ReplaceAll(appInfo.AppGateway, ",", "\n")
160 | api.SuccessWithData("success", appInfo)
161 | }
162 | }
163 |
164 | // AppGetList
165 | // @desc 获取用户app列表
166 | // @route GET /app/list
167 | func AppGetList(c *gin.Context) {
168 | var req dto.AppListRequest
169 | api := apiutil.New(c)
170 |
171 | // 支持从查询参数获取
172 | pageTmp := c.DefaultQuery("page", "1")
173 | limitTmp := c.DefaultQuery("per_page", "10")
174 |
175 | var err error
176 | var page, limit int
177 | if page, err = strconv.Atoi(pageTmp); err != nil || page < 1 {
178 | page = 1
179 | }
180 | if limit, err = strconv.Atoi(limitTmp); err != nil || limit < 1 || limit > 100 {
181 | limit = 10
182 | }
183 |
184 | // 尝试从JSON获取,如果有的话会覆盖query参数
185 | if c.Request.ContentLength > 0 {
186 | if err := c.ShouldBindJSON(&req); err == nil {
187 | if req.Page > 0 {
188 | page = req.Page
189 | }
190 | if req.PerPage > 0 && req.PerPage <= 100 {
191 | limit = req.PerPage
192 | }
193 | }
194 | }
195 | offset := (page - 1) * limit
196 | // 获取用户Id
197 | userId := c.GetInt("userId")
198 |
199 | // 获取用户app数量
200 | var appCounts int
201 | if appCounts, err = apputil.GetUserAppCount(userId); err != nil {
202 | api.Fail("server error")
203 | return
204 | }
205 | if appCounts == 0 {
206 | api.SuccessWithData("success", gin.H{
207 | "total": 0,
208 | "list": []gin.H{},
209 | })
210 | return
211 | }
212 |
213 | // 获取用户app列表
214 | var appList []apputil.AppBaseStruct
215 | if appList, err = apputil.GetUserAppList(userId, limit, offset); err != nil {
216 | api.FailWithData("获取失败", gin.H{
217 | "err": err.Error(),
218 | })
219 | return
220 | }
221 | if len(appList) == 0 {
222 | api.Fail("当页无数据")
223 | return
224 | }
225 |
226 | api.SuccessWithData("success", gin.H{
227 | "total": appCounts,
228 | "list": appList,
229 | })
230 | }
231 |
--------------------------------------------------------------------------------
/app/controller/user.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "github.com/gin-gonic/gin"
7 | "github.com/soxft/openid-go/app/dto"
8 | "github.com/soxft/openid-go/app/model"
9 | "github.com/soxft/openid-go/library/apiutil"
10 | "github.com/soxft/openid-go/library/codeutil"
11 | "github.com/soxft/openid-go/library/mailutil"
12 | "github.com/soxft/openid-go/library/toolutil"
13 | "github.com/soxft/openid-go/library/userutil"
14 | "github.com/soxft/openid-go/process/dbutil"
15 | "github.com/soxft/openid-go/process/queueutil"
16 | "log"
17 | "time"
18 | )
19 |
20 | // UserStatus
21 | // @description 判断用户登录状态
22 | // @router GET /user/status
23 | func UserStatus(c *gin.Context) {
24 | api := apiutil.New(c)
25 | // 中间件中已经处理, 直接输出
26 | api.Success("logon")
27 | }
28 |
29 | // UserInfo
30 | // @description 获取用户信息
31 | // @router GET /user/info
32 | func UserInfo(c *gin.Context) {
33 | api := apiutil.New(c)
34 |
35 | userId := c.GetInt("userId")
36 | api.SuccessWithData("success", gin.H{
37 | "userId": userId,
38 | "username": c.GetString("username"),
39 | "email": c.GetString("email"),
40 | "lastTime": c.GetInt64("lastTime"),
41 | })
42 | }
43 |
44 | // UserLogout
45 | // @description 用户退出
46 | func UserLogout(c *gin.Context) {
47 | api := apiutil.New(c)
48 | _ = userutil.SetJwtExpire(c, c.GetString("token"))
49 | api.Success("success")
50 | }
51 |
52 | // UserPasswordUpdate
53 | // @description 修改用户密码
54 | // @router PATCH /user/password/update
55 | func UserPasswordUpdate(c *gin.Context) {
56 | var req dto.UserPasswordUpdateRequest
57 | api := apiutil.New(c)
58 |
59 | if err := dto.BindJSON(c, &req); err != nil {
60 | api.Fail("请求参数错误")
61 | return
62 | }
63 |
64 | userId := c.GetInt("userId")
65 | username := c.GetString("username")
66 |
67 | if !toolutil.IsPassword(req.NewPassword) {
68 | api.Fail("密码应在8~64位")
69 | return
70 | }
71 | // verify old password
72 | if _, err := userutil.CheckPassword(username, req.OldPassword); errors.Is(err, userutil.ErrPasswd) {
73 | api.Fail("旧密码错误")
74 | return
75 | } else if err != nil {
76 | api.Fail("system err")
77 | return
78 | }
79 |
80 | // change password
81 | var err error
82 | var newPwd string
83 | if newPwd, err = userutil.GeneratePwd(req.NewPassword); err != nil {
84 | log.Printf("generate password failed: %v", err)
85 | api.Fail("system error")
86 | return
87 | }
88 |
89 | result := dbutil.D.Model(model.Account{}).Where(&model.Account{ID: userId}).
90 | Updates(&model.Account{Password: newPwd})
91 |
92 | if result.Error != nil {
93 | log.Printf("[ERROR] UserPasswordUpdate %v", result.Error)
94 | api.Fail("system error")
95 | return
96 | } else if result.RowsAffected == 0 {
97 | api.Fail("用户不存在")
98 | return
99 | }
100 |
101 | // make jwt token expire
102 | _ = userutil.SetJwtExpire(c, c.GetString("token"))
103 |
104 | // send safe notify email
105 | userutil.PasswordChangeNotify(c.GetString("email"), time.Now())
106 |
107 | api.Success("修改成功, 请重新登录")
108 | }
109 |
110 | // UserEmailUpdateCode
111 | // @description 修改邮箱 的 发送邮箱验证码 至新邮箱
112 | // @router POST /user/email/update/code
113 | func UserEmailUpdateCode(c *gin.Context) {
114 | var req struct {
115 | Password string `json:"password" binding:"required"`
116 | NewEmail string `json:"new_email" binding:"required,email"`
117 | }
118 | api := apiutil.New(c)
119 |
120 | if err := dto.BindJSON(c, &req); err != nil {
121 | api.Fail("请求参数错误")
122 | return
123 | }
124 |
125 | username := c.GetString("username")
126 | newEmail := req.NewEmail
127 |
128 | if !toolutil.IsEmail(newEmail) {
129 | api.Fail("非法的邮箱格式")
130 | return
131 | }
132 |
133 | // verify old password
134 | if _, err := userutil.CheckPassword(username, req.Password); errors.Is(err, userutil.ErrPasswd) {
135 | api.Fail("旧密码错误")
136 | return
137 | } else if err != nil {
138 | api.Fail("system err")
139 | return
140 | }
141 |
142 | if exist, err := userutil.CheckEmailExists(newEmail); err != nil {
143 | api.Fail("system err")
144 | return
145 | } else if exist {
146 | api.Fail("邮箱已存在")
147 | return
148 | }
149 |
150 | // 防止频繁发送验证码
151 | if beacon, err := mailutil.CheckBeacon(c, newEmail); beacon || err != nil {
152 | api.Fail("code send too frequently")
153 | return
154 | }
155 |
156 | // send mail
157 | coder := codeutil.New(c)
158 | verifyCode := coder.Create(4)
159 | _msg, _ := json.Marshal(mailutil.Mail{
160 | ToAddress: newEmail,
161 | Subject: verifyCode + " 为您的验证码",
162 | Content: "您正在申请修改邮箱, 您的验证码为: " + verifyCode + ", 有效期10分钟",
163 | Typ: "emailChange",
164 | })
165 |
166 | if err := coder.Save("emailChange", newEmail, verifyCode, 60*time.Minute); err != nil {
167 | api.Fail("send code failed")
168 | return
169 | }
170 | if err := queueutil.Q.Publish("mail", string(_msg), 0); err != nil {
171 | coder.Consume("emailChange", newEmail) // 删除code
172 | api.Fail("send code failed")
173 | return
174 | }
175 | _ = mailutil.CreateBeacon(c, newEmail, 120)
176 |
177 | api.Success("发送成功")
178 | }
179 |
180 | // UserEmailUpdate
181 | // @description 修改用户邮箱
182 | // @router PATCH /user/email/update
183 | func UserEmailUpdate(c *gin.Context) {
184 | var req dto.UserEmailUpdateRequest
185 | api := apiutil.New(c)
186 |
187 | if err := dto.BindJSON(c, &req); err != nil {
188 | api.Fail("请求参数错误")
189 | return
190 | }
191 |
192 | newEmail := req.Email
193 | code := req.Code
194 |
195 | if !toolutil.IsEmail(newEmail) {
196 | api.Fail("非法的邮箱格式")
197 | return
198 | }
199 |
200 | // verify code
201 | coder := codeutil.New(c)
202 | if pass, err := coder.Check("emailChange", newEmail, code); !pass || err != nil {
203 | api.Fail("验证码错误或已过期")
204 | return
205 | }
206 |
207 | // update email
208 | userId := c.GetInt("userId") // get userid from middleware
209 | result := dbutil.D.Model(&model.Account{}).Where(&model.Account{ID: userId}).Update("email", newEmail)
210 | if result.Error != nil {
211 | log.Printf("[ERROR] UserEmailUpdate %v", result.Error)
212 | api.Fail("system error")
213 | return
214 | } else if result.RowsAffected == 0 {
215 | api.Fail("用户不存在")
216 | return
217 | }
218 |
219 | coder.Consume("emailChange", newEmail)
220 | userutil.EmailChangeNotify(c.GetString("email"), time.Now())
221 | _ = userutil.SetJwtExpire(c, c.GetString("token"))
222 | api.Success("修改成功, 请重新登录")
223 | }
224 |
--------------------------------------------------------------------------------
/lang-go-Standards.md:
--------------------------------------------------------------------------------
1 | # Go 语言开发规范
2 |
3 | ## 概述
4 |
5 | 本文档旨在为基于 Go 语言的项目提供一套统一的开发规范,以确保代码的一致性、可读性和可维护性。本文档的目标读者是 LLM 代码代理和所有项目参与者。规范内容基于 Go 社区的最佳实践,并结合了本项目的特定风格。
6 |
7 | ## 1. 通用开发原则
8 |
9 | ### 1.1. 项目结构标准
10 |
11 | 项目应遵循分层架构,将不同职责的代码分离到独立的目录中。推荐使用以下结构:
12 |
13 | ```
14 | .
15 | ├── api/ # 外部 API 接口定义 (例如 OpenAPI/Swagger)
16 | ├── app/ # 应用核心逻辑
17 | │ ├── controller/ # 控制器/处理器层,处理 HTTP 请求
18 | │ ├── middleware/ # 中间件
19 | │ └── model/ # 数据模型 (GORM structs)
20 | ├── config/ # 配置加载与管理
21 | ├── core/ # 核心引导程序
22 | ├── docs/ # 项目文档
23 | ├── library/ # 通用库/工具函数
24 | ├── process/ # 后台进程、数据库、缓存等初始化
25 | └── main.go # 程序入口
26 | ```
27 |
28 | ### 1.2. 版本控制规范
29 |
30 | - **分支模型**: 推荐使用 `Git Flow` 或类似的策略。
31 | - `main`: 稳定的主分支,用于生产发布。
32 | - `develop`: 开发分支,集成所有已完成的功能。
33 | - `feature/xxx`: 功能开发分支。
34 | - `hotfix/xxx`: 紧急修复分支。
35 | - **提交信息**: 遵循 `Conventional Commits` 规范。
36 | - 格式: `(): `
37 | - 示例: `feat(passkey): add support for discoverable login`
38 | - 常用 `type`: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`。
39 |
40 | ### 1.3. 文档编写要求
41 |
42 | - 所有公开的函数、类型和常量都应有文档注释。
43 | - API 端点应在对应的 `controller` 函数注释中清晰标明,包含请求方法和路径。
44 | - 复杂的业务逻辑应在代码中添加必要的行内注释。
45 |
46 | ### 1.4. 代码审查流程
47 |
48 | - 所有代码变更必须通过 Pull Request (PR) 提交。
49 | - PR 必须至少有一位其他成员审查通过后才能合并。
50 | - 审查重点:代码风格、逻辑正确性、测试覆盖率、文档完整性。
51 |
52 | ## 2. Go 语言特定规范
53 |
54 | ### 2.1. 文件组织
55 |
56 | - **文件命名**: 使用小写下划线 `snake_case.go`。
57 | - **目录结构**:
58 | - 按功能或领域划分包(`package`)。
59 | - 包名应简短、清晰,并使用小写。
60 | - 避免使用 `util` 或 `common` 等模糊的包名,优先使用功能性命名,如 `apiutil`, `userutil`。
61 |
62 | ### 2.2. 命名约定
63 |
64 | - **变量**: 使用 `camelCase`。
65 | - **函数/方法**: 公开函数使用 `PascalCase`,私有函数使用 `camelCase`。
66 | - **接口**: 接口名以 `er` 结尾,如 `Reader`, `Writer`。
67 | - **常量**: 使用 `PascalCase`。
68 | - **包名**: 小写,简明扼要。
69 | - **错误变量**: 以 `Err` 开头,如 `ErrSessionNotFound`。
70 |
71 | ### 2.3. 代码风格
72 |
73 | - **格式化**: 所有代码必须使用 `gofmt` 或 `goimports` 进行格式化。
74 | - **缩进**: 使用制表符 `\t`。
75 | - **行长度**: 建议不超过 120 个字符。
76 | - **导入/包管理**:
77 | - 使用 `goimports` 自动管理导入顺序。
78 | - 导入顺序:标准库、第三方库、项目内库,组间用空行分隔。
79 | - 依赖管理使用 `Go Modules` (`go.mod`, `go.sum`)。
80 |
81 | ### 2.4. 注释规范
82 |
83 | - **文档字符串**:
84 | - 为所有公开的函数、类型、常量和变量提供注释。
85 | - 注释应以被注释对象的名字开头。
86 | - 示例:
87 | ```go
88 | // PasskeyRegistrationOptions 获取 Passkey 注册选项
89 | //
90 | // GET /passkey/register/options
91 | func PasskeyRegistrationOptions(c *gin.Context) { ... }
92 | ```
93 | - **行内注释**: 用于解释复杂或不直观的代码逻辑。
94 | - **TODO 注释**:
95 | - 使用 `// TODO:` 格式标记待办事项。
96 | - 最好能附上相关 issue 编号或责任人。
97 | - 示例: `// TODO(#123): Refactor this to improve performance.`
98 |
99 | ### 2.5. 错误处理
100 |
101 | - **总是检查错误**: 不要忽略函数返回的 `error`,除非明确知道可以安全地忽略。
102 | - **错误信息**:
103 | - 错误信息应为小写,不以标点符号结尾。
104 | - 使用 `fmt.Errorf` 添加上下文信息: `fmt.Errorf("passkey begin registration: %w", err)`。
105 | - 使用 `%w` 来包装底层错误,以便上层可以使用 `errors.Is` 或 `errors.As`。
106 | - **错误返回**:
107 | - 在函数或方法的开头处理 "happy path" 之前的卫语句(guard clauses)。
108 | - 错误应尽早返回。
109 | - 示例:
110 | ```go
111 | account, err := getAccount(c)
112 | if err != nil {
113 | api.Fail("user not found")
114 | return
115 | }
116 | ```
117 |
118 | ### 2.6. 测试规范
119 |
120 | - **测试文件**: 测试文件命名为 `_test.go`。
121 | - **测试函数**: 测试函数以 `Test` 开头,例如 `func TestMyFunction(t *testing.T)`。
122 | - **测试覆盖率**: 核心业务逻辑的测试覆盖率应达到 80% 以上。
123 | - **测试驱动开发 (TDD)**: 鼓励在新功能开发前先编写测试用例。
124 | - **Mocking**: 使用 `gomock` 或 `testify/mock` 等库来模拟依赖。
125 |
126 | ## 3. 工具配置
127 |
128 | ### 3.1. 编辑器配置 (VS Code)
129 |
130 | 在项目根目录创建 `.vscode/settings.json`:
131 |
132 | ```json
133 | {
134 | "go.formatTool": "goimports",
135 | "go.useLanguageServer": true,
136 | "editor.formatOnSave": true,
137 | "[go]": {
138 | "editor.defaultFormatter": "golang.go"
139 | },
140 | "gopls": {
141 | "ui.semanticTokens": true
142 | }
143 | }
144 | ```
145 |
146 | ### 3.2. Lint 规则配置
147 |
148 | 使用 `golangci-lint` 作为代码检查工具。在项目根目录创建 `.golangci.yml`:
149 |
150 | ```yaml
151 | run:
152 | timeout: 5m
153 | skip-dirs:
154 | - vendor/
155 |
156 | linters:
157 | enable:
158 | - gofmt
159 | - goimports
160 | - revive
161 | - govet
162 | - staticcheck
163 | - unused
164 | - errcheck
165 |
166 | issues:
167 | exclude-rules:
168 | - path: _test\.go
169 | linters:
170 | - errcheck # Don't require error checking in tests
171 | ```
172 |
173 | ### 3.3. 预提交钩子配置
174 |
175 | 使用 `pre-commit` 框架来自动化代码检查。在项目根目录创建 `.pre-commit-config.yaml`:
176 |
177 | ```yaml
178 | repos:
179 | - repo: https://github.com/pre-commit/pre-commit-hooks
180 | rev: v4.3.0
181 | hooks:
182 | - id: check-yaml
183 | - id: end-of-file-fixer
184 | - id: trailing-whitespace
185 | - repo: https://github.com/golangci/golangci-lint
186 | rev: v1.50.1
187 | hooks:
188 | - id: golangci-lint
189 | ```
190 |
191 | ## 4. 代码审查标准
192 |
193 | - **可读性**: 代码是否清晰易懂?
194 | - **一致性**: 是否遵循了本规范?
195 | - **简洁性**: 是否有不必要的复杂性?
196 | - **正确性**: 代码是否能正确实现需求?
197 | - **测试**: 是否有足够的测试覆盖?
198 | - **文档**: 注释和文档是否完整?
199 |
200 | ## 5. 最佳实践示例
201 |
202 | ### 5.1. 良好的代码示例
203 |
204 | **清晰的函数定义和错误处理**
205 |
206 | ```go
207 | // findUserByID finds a user by their ID.
208 | // It returns the user and an error if one occurred.
209 | func findUserByID(ctx context.Context, id int) (*model.User, error) {
210 | if id <= 0 {
211 | return nil, errors.New("invalid user id")
212 | }
213 |
214 | var user model.User
215 | if err := db.WithContext(ctx).First(&user, id).Error; err != nil {
216 | if errors.Is(err, gorm.ErrRecordNotFound) {
217 | return nil, fmt.Errorf("user with id %d not found", id)
218 | }
219 | return nil, fmt.Errorf("database error: %w", err)
220 | }
221 |
222 | return &user, nil
223 | }
224 | ```
225 |
226 | ### 5.2. 应避免的代码示例
227 |
228 | **忽略错误和模糊的命名**
229 |
230 | ```go
231 | // Bad example
232 | func GetUser(id int) *model.User {
233 | user := model.User{}
234 | // Error is ignored!
235 | db.First(&user, id)
236 | return &user
237 | }
238 | ```
239 |
240 | **嵌套过深**
241 |
242 | ```go
243 | // Bad example
244 | func processRequest(c *gin.Context) {
245 | if c.Request.Method == "POST" {
246 | err := c.Request.ParseForm()
247 | if err == nil {
248 | // ... more nested logic
249 | } else {
250 | // handle error
251 | }
252 | } else {
253 | // handle other methods
254 | }
255 | }
256 | ```
257 |
258 | ---
259 | 这份规范旨在成为一个动态文档,随着项目的发展和团队的成长而不断完善。
--------------------------------------------------------------------------------
/app/controller/passkey.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "encoding/base64"
5 | "errors"
6 | "log"
7 | "strconv"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/go-webauthn/webauthn/protocol"
11 | "gorm.io/gorm"
12 |
13 | "github.com/soxft/openid-go/app/dto"
14 | "github.com/soxft/openid-go/app/model"
15 | "github.com/soxft/openid-go/library/apiutil"
16 | "github.com/soxft/openid-go/library/passkey"
17 | "github.com/soxft/openid-go/library/userutil"
18 | "github.com/soxft/openid-go/process/dbutil"
19 | )
20 |
21 | // PasskeyRegistrationOptions 获取 Passkey 注册选项
22 | //
23 | // GET /passkey/register/options
24 | func PasskeyRegistrationOptions(c *gin.Context) {
25 | api := apiutil.New(c)
26 |
27 | account, err := getAccount(c)
28 | if err != nil {
29 | api.Fail("user not found")
30 | return
31 | }
32 |
33 | options, err := passkey.BeginRegistration(c.Request.Context(), *account)
34 | if err != nil {
35 | log.Printf("[ERROR] passkey begin registration failed: %v", err)
36 | api.Fail("生成 Passkey 参数失败")
37 | return
38 | }
39 |
40 | api.SuccessWithData("success", options)
41 | }
42 |
43 | // PasskeyRegistrationFinish 完成 Passkey 注册
44 | //
45 | // POST /passkey/register
46 | func PasskeyRegistrationFinish(c *gin.Context) {
47 | api := apiutil.New(c)
48 | account, err := getAccount(c)
49 | if err != nil {
50 | api.Fail("user not found")
51 | return
52 | }
53 |
54 | // 解析 JSON 格式的 WebAuthn 响应
55 | parsed, err := protocol.ParseCredentialCreationResponse(c.Request)
56 | if err != nil {
57 | log.Printf("[ERROR] parse credential creation failed: %v", err)
58 | api.Fail("invalid payload")
59 | return
60 | }
61 |
62 | // 获取备注(可选)
63 | var reqBody dto.PasskeyRegistrationFinishRequest
64 | _ = c.ShouldBindJSON(&reqBody)
65 | remark := reqBody.Remark
66 |
67 | // 完成注册,并传递备注
68 | credential, err := passkey.CompleteRegistrationWithRemark(c.Request.Context(), *account, parsed, remark)
69 | if err != nil {
70 | if errors.Is(err, passkey.ErrSessionNotFound) {
71 | api.Fail("挑战已过期,请重试")
72 | return
73 | }
74 | log.Printf("[ERROR] passkey finish registration failed: %v", err)
75 | api.Fail("注册失败")
76 | return
77 | }
78 |
79 | api.SuccessWithData("success", gin.H{
80 | "passkeyId": credential.ID,
81 | })
82 | }
83 |
84 | // PasskeyLoginOptions 获取 Passkey 登录选项(无需用户名)
85 | //
86 | // GET /passkey/login/options
87 | func PasskeyLoginOptions(c *gin.Context) {
88 | api := apiutil.New(c)
89 |
90 | // 模式2:无用户名登录(无条件 UI)
91 | // 生成通用的登录挑战,不指定 allowCredentials
92 | options, err := passkey.BeginDiscoverableLogin(c.Request.Context())
93 | if err != nil {
94 | log.Printf("[ERROR] passkey begin discoverable login failed: %v", err)
95 | api.Fail("生成登录参数失败")
96 | return
97 | }
98 |
99 | api.SuccessWithData("success", options)
100 |
101 | }
102 |
103 | // PasskeyLoginFinish 完成 Passkey 登录
104 | //
105 | // POST /passkey/login
106 | func PasskeyLoginFinish(c *gin.Context) {
107 | api := apiutil.New(c)
108 |
109 | // 解析 JSON 格式的 WebAuthn 响应
110 | parsed, err := protocol.ParseCredentialRequestResponse(c.Request)
111 | if err != nil {
112 | log.Printf("[ERROR] parse credential request failed: %v", err)
113 | api.Fail("invalid credential")
114 | return
115 | }
116 |
117 | if parsed == nil {
118 | log.Printf("[ERROR] parsed credential is nil")
119 | api.Fail("invalid credential")
120 | return
121 | }
122 |
123 | // 方式1:通过 userHandle(如果客户端提供了)
124 | var account model.Account
125 | var found bool
126 |
127 | if len(parsed.Response.UserHandle) > 0 {
128 | // userHandle 是用户 ID 的 base64 编码
129 | userIDStr := string(parsed.Response.UserHandle)
130 | if userID, err := strconv.Atoi(userIDStr); err == nil {
131 | if err := dbutil.D.Where("id = ?", userID).Take(&account).Error; err == nil {
132 | found = true
133 | log.Printf("[DEBUG] Found user by userHandle: %d", userID)
134 | }
135 | }
136 | }
137 |
138 | // 方式2:通过 credential ID 查找
139 | if !found {
140 | credentialID := passkey.EncodeKey(parsed.RawID)
141 | var passkeyRecord model.PassKey
142 | if err := dbutil.D.Where("credential_id = ?", credentialID).Take(&passkeyRecord).Error; err == nil {
143 | if err := dbutil.D.Where("id = ?", passkeyRecord.UserID).Take(&account).Error; err == nil {
144 | found = true
145 | log.Printf("[DEBUG] Found user by credential ID: %d", passkeyRecord.UserID)
146 | }
147 | }
148 | }
149 |
150 | if !found {
151 | log.Printf("[ERROR] User not found for credential ID: %s", base64.StdEncoding.EncodeToString(parsed.RawID))
152 | api.Fail("invalid credential")
153 | return
154 | }
155 |
156 | // 验证登录
157 | passkeyCredential, err := passkey.CompleteDiscoverableLogin(c.Request.Context(), account, parsed)
158 | if err != nil {
159 | if errors.Is(err, passkey.ErrSessionNotFound) {
160 | api.Fail("挑战已过期,请重试")
161 | return
162 | }
163 | if errors.Is(err, passkey.ErrNoPasskeyRegistered) {
164 | api.Fail("未绑定 Passkey")
165 | return
166 | }
167 | log.Printf("[ERROR] passkey finish login failed: %v", err)
168 | api.Fail("登录失败")
169 | return
170 | }
171 |
172 | // 登录成功,生成 JWT Token
173 | token, err := generateLoginToken(c, account.ID)
174 | if err != nil {
175 | api.Fail("system error")
176 | return
177 | }
178 |
179 | api.SuccessWithData("success", gin.H{
180 | "token": token,
181 | "passkeyId": passkeyCredential.ID,
182 | "username": account.Username,
183 | "email": account.Email,
184 | })
185 | }
186 |
187 | // PasskeyList 列出用户绑定的所有 Passkey
188 | //
189 | // GET /passkey
190 | func PasskeyList(c *gin.Context) {
191 | api := apiutil.New(c)
192 | account, err := getAccount(c)
193 | if err != nil {
194 | api.Fail("user not found")
195 | return
196 | }
197 |
198 | passkeys, err := passkey.ListUserPasskeys(account.ID)
199 | if err != nil {
200 | log.Printf("[ERROR] passkey list failed: %v", err)
201 | api.Fail("获取失败")
202 | return
203 | }
204 |
205 | summaries := make([]passkey.Summary, 0, len(passkeys))
206 | for _, item := range passkeys {
207 | summaries = append(summaries, passkey.Summary{
208 | ID: item.ID,
209 | Remark: item.Remark, // 包含备注信息
210 | CreatedAt: item.CreatedAt,
211 | LastUsedAt: item.LastUsedAt,
212 | CloneWarning: item.CloneWarning,
213 | SignCount: item.SignCount,
214 | Transports: passkey.SplitTransports(item.Transport),
215 | })
216 | }
217 |
218 | api.SuccessWithData("success", summaries)
219 | }
220 |
221 | // PasskeyDelete 删除指定 Passkey
222 | //
223 | // DELETE /passkey/:id
224 | func PasskeyDelete(c *gin.Context) {
225 | api := apiutil.New(c)
226 | account, err := getAccount(c)
227 | if err != nil {
228 | api.Fail("user not found")
229 | return
230 | }
231 |
232 | passkeyID, err := strconv.Atoi(c.Param("id"))
233 | if err != nil || passkeyID <= 0 {
234 | api.Fail("invalid id")
235 | return
236 | }
237 |
238 | if err := passkey.DeleteUserPasskey(account.ID, passkeyID); err != nil {
239 | if errors.Is(err, gorm.ErrRecordNotFound) {
240 | api.Fail("Passkey 不存在")
241 | return
242 | }
243 | log.Printf("[ERROR] passkey delete failed: %v", err)
244 | api.Fail("删除失败")
245 | return
246 | }
247 |
248 | api.Success("success")
249 | }
250 |
251 | func getAccount(c *gin.Context) (*model.Account, error) {
252 | userID := c.GetInt("userId")
253 | if userID == 0 {
254 | return nil, errors.New("empty user id")
255 | }
256 |
257 | var account model.Account
258 | if err := dbutil.D.Where("id = ?", userID).Take(&account).Error; err != nil {
259 | return nil, err
260 | }
261 |
262 | return &account, nil
263 | }
264 |
265 | func generateLoginToken(c *gin.Context, userID int) (string, error) {
266 | return userutil.GenerateJwt(userID, c.ClientIP())
267 | }
268 |
--------------------------------------------------------------------------------
/library/apputil/apputil.go:
--------------------------------------------------------------------------------
1 | package apputil
2 |
3 | import (
4 | "errors"
5 | "github.com/soxft/openid-go/app/model"
6 | "github.com/soxft/openid-go/config"
7 | "github.com/soxft/openid-go/library/toolutil"
8 | "github.com/soxft/openid-go/process/dbutil"
9 | "gorm.io/gorm"
10 | "html"
11 | "log"
12 | "strconv"
13 | "strings"
14 | "time"
15 | )
16 |
17 | // CheckName
18 | // 检测应用名称合法性
19 | func CheckName(name string) bool {
20 | if html.EscapeString(name) != name {
21 | return false
22 | }
23 | if len(name) < 2 || len(name) > 20 {
24 | return false
25 | }
26 | return true
27 | }
28 |
29 | // CheckGateway
30 | // 检测应用网关合法性
31 | func CheckGateway(gateway string) bool {
32 | //log.Printf("[apputil] check gateway: %s", gateway)
33 | if len(gateway) < 4 || len(gateway) > 200 {
34 | return false
35 | }
36 | // 如果不包含 小数点
37 | if !strings.Contains(gateway, ".") {
38 | return false
39 | }
40 | // 是否包含特殊字符
41 | if strings.ContainsAny(gateway, "~!@#$%^&*()_+=|\\{}[];'\",/<>?") {
42 | return false
43 | }
44 | // 不能 以 . 开头 或 结尾
45 | if strings.HasPrefix(gateway, ".") || strings.HasSuffix(gateway, ".") {
46 | return false
47 | }
48 | // 以小数点分割, 每段不能超过 63 个字符
49 | parts := strings.Split(gateway, ".")
50 | for _, part := range parts {
51 | if len(part) > 63 {
52 | return false
53 | }
54 | // 是否存在空格
55 | if strings.Contains(part, " ") {
56 | return false
57 | }
58 | }
59 | return true
60 | }
61 |
62 | func CreateApp(userId int, appName string) (bool, error) {
63 | if userId == 0 {
64 | return false, errors.New("userId is invalid")
65 | }
66 | if !CheckName(appName) {
67 | return false, errors.New("app name is invalid")
68 | }
69 | // 判断用户app数量是否超过限制
70 | counts, err := GetUserAppCount(userId)
71 | if err != nil {
72 | return false, err
73 | }
74 | if counts >= config.Developer.AppLimit {
75 | return false, errors.New("the number of app exceeds the limit")
76 | }
77 |
78 | // 创建app
79 | appId, err := generateAppId()
80 | if err != nil {
81 | return false, err
82 | }
83 |
84 | if result := dbutil.D.Create(&model.App{
85 | UserId: userId,
86 | AppId: appId,
87 | AppName: appName,
88 | AppSecret: generateAppSecret(),
89 | AppGateway: "",
90 | }); result.Error != nil || result.RowsAffected == 0 {
91 | log.Printf("[apputil] create app failed: %s", result.Error.Error())
92 | return false, errors.New("创建应用时发生错误, 请稍后再试")
93 | }
94 |
95 | return true, nil
96 | }
97 |
98 | // DeleteUserApp
99 | // 删除用户App
100 | func DeleteUserApp(appId string) (bool, error) {
101 | // 开启 事物
102 | err := dbutil.D.Transaction(func(tx *gorm.DB) error {
103 | var App model.App
104 | var OpenId model.OpenId
105 |
106 | // 删除app表内数据
107 | err := tx.Where(model.App{AppId: appId}).Delete(&App).Error
108 | if err != nil {
109 | return errors.New("system error")
110 | }
111 | // 删除openId表内数据
112 | err = tx.Where(model.OpenId{AppId: appId}).Delete(&OpenId).Error
113 | if err != nil {
114 | return errors.New("system error")
115 | }
116 |
117 | return nil
118 | })
119 |
120 | if err != nil {
121 | log.Printf("[ERROR] DeleteUserApp error: %s", err)
122 | return false, err
123 | }
124 | return true, nil
125 | }
126 |
127 | // GetUserAppList
128 | // @description: 获取用户app列表
129 | func GetUserAppList(userId, limit, offset int) ([]AppBaseStruct, error) {
130 | // 开始获取
131 | var appList []AppBaseStruct
132 | var appListRaw []model.App
133 | err := dbutil.D.Model(model.App{}).Select("id, app_id, app_name, create_at").Where(model.App{UserId: userId}).Order("id desc").Limit(limit).Offset(offset).Find(&appListRaw).Error
134 | if err != nil {
135 | log.Printf("[ERROR] GetUserAppList error: %s", err)
136 | return nil, errors.New("GetUserAppList error")
137 | }
138 | for _, app := range appListRaw {
139 | appList = append(appList, AppBaseStruct{
140 | Id: app.ID,
141 | AppId: app.AppId,
142 | AppName: app.AppName,
143 | CreateAt: app.CreateAt,
144 | })
145 | }
146 | return appList, nil
147 | }
148 |
149 | // GetUserAppCount
150 | // 获取用户的app数量
151 | func GetUserAppCount(userId int) (int, error) {
152 | var count int64
153 | err := dbutil.D.Model(&model.App{}).Where(model.App{UserId: userId}).Count(&count).Error
154 | if err != nil {
155 | log.Printf("[ERROR] GetUserAppCount error: %s", err)
156 | return 0, errors.New("GetUserAppCount error")
157 | }
158 |
159 | countInt := int(count)
160 | return countInt, nil
161 | }
162 |
163 | func GetAppInfo(appId string) (AppFullInfoStruct, error) {
164 | var appInfo AppFullInfoStruct
165 | var appInfoRaw model.App
166 |
167 | err := dbutil.D.Model(&model.App{}).Select("id, user_id, app_id, app_name, app_secret, app_gateway, create_at").Where(model.App{AppId: appId}).Take(&appInfoRaw).Error
168 | if errors.Is(err, gorm.ErrRecordNotFound) {
169 | return appInfo, ErrAppNotExist
170 | } else if err != nil {
171 | log.Printf("[ERROR] GetAppInfo error: %s", err)
172 | return appInfo, errors.New("server error")
173 | }
174 | appInfo = AppFullInfoStruct{
175 | Id: appInfoRaw.ID,
176 | AppUserId: appInfoRaw.UserId,
177 | AppId: appInfoRaw.AppId,
178 | AppName: appInfoRaw.AppName,
179 | AppSecret: appInfoRaw.AppSecret,
180 | AppGateway: appInfoRaw.AppGateway,
181 | CreateAt: appInfoRaw.CreateAt,
182 | }
183 | return appInfo, nil
184 | }
185 |
186 | // CheckAppSecret
187 | // @description: 检查appSecret
188 | func CheckAppSecret(appId string, appSecret string) error {
189 | appInfo, err := GetAppInfo(appId)
190 | if err != nil {
191 | return err
192 | }
193 | if appInfo.AppSecret != appSecret {
194 | return ErrAppSecretNotMatch
195 | }
196 | return nil
197 | }
198 |
199 | // GenerateAppId
200 | // 创建唯一的appid
201 | func generateAppId() (string, error) {
202 | timeUnix := time.Now().Unix()
203 | Tp := strconv.FormatInt(timeUnix, 10)
204 |
205 | appId := time.Now().Format("20060102") + Tp[len(Tp)-4:] + strconv.Itoa(toolutil.RandInt(4))
206 | if exists, err := checkAppIdExists(appId); err != nil {
207 | return "", err
208 | } else if exists {
209 | return generateAppId()
210 | }
211 | return appId, nil
212 | }
213 |
214 | // CheckIfUserApp
215 | // 判断是否为该用户的app
216 | func CheckIfUserApp(appId string, userId int) (bool, error) {
217 | var appUserId int
218 | err := dbutil.D.Model(&model.App{}).Select("user_id").Where(model.App{AppId: appId}).Take(&appUserId).Error
219 | if errors.Is(err, gorm.ErrRecordNotFound) {
220 | log.Printf("[ERROR] CheckIfUserApp error: %s", err)
221 |
222 | return false, errors.New("app not exist")
223 | } else if err != nil {
224 | log.Printf("[ERROR] CheckIfUserApp error: %s", err)
225 |
226 | return false, errors.New("server error")
227 | }
228 |
229 | if appUserId != userId {
230 | return false, nil
231 | }
232 | return true, nil
233 | }
234 |
235 | // ReGenerateSecret
236 | // 重新生成新的 appSecret
237 | func ReGenerateSecret(appId string) (string, error) {
238 | appSecret := generateAppSecret()
239 | err := dbutil.D.Model(&model.App{}).
240 | Where(model.App{AppId: appId}).
241 | Updates(model.App{AppSecret: appSecret}).Error
242 | if err != nil {
243 | log.Printf("[ERROR] ReGenerateAppSecret error: %s", err)
244 | return "", errors.New("server error")
245 | }
246 | return appSecret, nil
247 | }
248 |
249 | // generateAppSecret
250 | // 创建唯一的appSecret
251 | func generateAppSecret() string {
252 | a := toolutil.Md5(time.Now().Format("20060102"))[:16]
253 | b := toolutil.Md5(strconv.FormatInt(time.Now().UnixNano(), 10))[:16]
254 | c := toolutil.RandStr(16)
255 | d := toolutil.RandStr(16)
256 | return strings.Join([]string{a, b, c, d}, ".")
257 | }
258 |
259 | // CheckAppIdExists
260 | // @description: check if appid exists
261 | func checkAppIdExists(appid string) (bool, error) {
262 | var ID int64
263 | err := dbutil.D.Model(model.App{}).Select("id").Where(model.App{AppId: appid}).Take(&ID).Error
264 | if errors.Is(err, gorm.ErrRecordNotFound) {
265 | return false, nil
266 | } else if err != nil {
267 | log.Printf("[ERROR] CheckAppIdExists: %s", err)
268 | return false, errors.New("system error")
269 | }
270 | return true, nil
271 | }
272 |
273 | // CheckRedirectUriIsMatchUserGateway
274 | // @description: 检测回调地址是否匹配用户网关
275 | func CheckRedirectUriIsMatchUserGateway(redirectUri string, GateWay string) bool {
276 | for _, gateway := range strings.Split(GateWay, ",") {
277 | if redirectUri == gateway {
278 | return true
279 | }
280 | }
281 |
282 | return false
283 | }
284 |
--------------------------------------------------------------------------------
/library/passkey/passkey.go:
--------------------------------------------------------------------------------
1 | package passkey
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/base64"
7 | "errors"
8 | "fmt"
9 | "net/http"
10 | "net/url"
11 | "sync"
12 | "time"
13 |
14 | "github.com/go-webauthn/webauthn/protocol"
15 | "github.com/go-webauthn/webauthn/webauthn"
16 | "github.com/soxft/openid-go/app/model"
17 | "github.com/soxft/openid-go/config"
18 | )
19 |
20 | var (
21 | waInitOnce sync.Once
22 | waInstance *webauthn.WebAuthn
23 | waInitErr error
24 |
25 | // ErrSessionNotFound 表示 passkey 挑战已过期或不存在
26 | ErrSessionNotFound = errors.New("passkey session not found")
27 | // ErrNoPasskeyRegistered 在用户未绑定任何 passkey 时返回
28 | ErrNoPasskeyRegistered = errors.New("no passkey registered")
29 | )
30 |
31 | const sessionTTL = 5 * time.Minute
32 |
33 | // Init 初始化 WebAuthn 配置
34 | func Init() error {
35 | waInitOnce.Do(func() {
36 | frontURL, err := url.Parse(config.Server.FrontUrl)
37 | if err != nil {
38 | waInitErr = fmt.Errorf("parse front url: %w", err)
39 | return
40 | }
41 |
42 | rpID := frontURL.Hostname()
43 | if rpID == "" {
44 | waInitErr = errors.New("front url hostname is empty")
45 | return
46 | }
47 |
48 | origin := fmt.Sprintf("%s://%s", frontURL.Scheme, frontURL.Host)
49 | waConfig := &webauthn.Config{
50 | RPDisplayName: config.Server.Name,
51 | RPID: rpID,
52 | RPOrigins: []string{origin},
53 | }
54 |
55 | waInstance, waInitErr = webauthn.New(waConfig)
56 | })
57 |
58 | return waInitErr
59 | }
60 |
61 | func ensureInit() error {
62 | if err := Init(); err != nil {
63 | return err
64 | }
65 | if waInstance == nil {
66 | return errors.New("passkey: webauthn instance not initialized")
67 | }
68 | return nil
69 | }
70 |
71 | // BeginRegistration 准备创建 passkey
72 | func BeginRegistration(ctx context.Context, account model.Account) (RegistrationOptions, error) {
73 | if err := ensureInit(); err != nil {
74 | return RegistrationOptions{}, err
75 | }
76 |
77 | passkeys, err := loadUserPasskeys(account.ID)
78 | if err != nil {
79 | return protocol.PublicKeyCredentialCreationOptions{}, err
80 | }
81 |
82 | waUser, err := newWebAuthnUser(account, passkeys)
83 | if err != nil {
84 | return protocol.PublicKeyCredentialCreationOptions{}, err
85 | }
86 |
87 | selection := protocol.AuthenticatorSelection{
88 | AuthenticatorAttachment: protocol.Platform,
89 | RequireResidentKey: boolPtr(true),
90 | ResidentKey: protocol.ResidentKeyRequirementRequired,
91 | UserVerification: protocol.VerificationPreferred,
92 | }
93 |
94 | creation, session, err := waInstance.BeginRegistration(waUser,
95 | webauthn.WithAuthenticatorSelection(selection),
96 | webauthn.WithConveyancePreference(protocol.PreferNoAttestation),
97 | )
98 | if err != nil {
99 | return RegistrationOptions{}, err
100 | }
101 |
102 | if err := storeRegistrationSession(ctx, account.ID, session, sessionTTL); err != nil {
103 | return protocol.PublicKeyCredentialCreationOptions{}, err
104 | }
105 |
106 | return creation.Response, nil
107 | }
108 |
109 | // CompleteRegistration 校验并保存 passkey(保留向后兼容)
110 | func CompleteRegistration(ctx context.Context, account model.Account, parsed *protocol.ParsedCredentialCreationData) (*model.PassKey, error) {
111 | return CompleteRegistrationWithRemark(ctx, account, parsed, "")
112 | }
113 |
114 | // CompleteRegistrationWithRemark 校验并保存 passkey(带备注)
115 | func CompleteRegistrationWithRemark(ctx context.Context, account model.Account, parsed *protocol.ParsedCredentialCreationData, remark string) (*model.PassKey, error) {
116 | if parsed == nil {
117 | return nil, errors.New("empty credential data")
118 | }
119 | if err := ensureInit(); err != nil {
120 | return nil, err
121 | }
122 |
123 | passkeys, err := loadUserPasskeys(account.ID)
124 | if err != nil {
125 | return nil, err
126 | }
127 |
128 | waUser, err := newWebAuthnUser(account, passkeys)
129 | if err != nil {
130 | return nil, err
131 | }
132 |
133 | session, err := loadRegistrationSession(ctx, account.ID)
134 | if err != nil {
135 | return nil, err
136 | }
137 |
138 | credential, err := waInstance.CreateCredential(waUser, *session, parsed)
139 | if err != nil {
140 | return nil, err
141 | }
142 |
143 | passkey, err := saveCredentialWithRemark(account.ID, credential, remark)
144 | if err != nil {
145 | return nil, err
146 | }
147 |
148 | _ = deleteRegistrationSession(ctx, account.ID)
149 | return passkey, nil
150 | }
151 |
152 | // BeginLogin 创建登录挑战(原函数保留向后兼容)
153 | func BeginLogin(ctx context.Context, account model.Account) (LoginOptions, error) {
154 | return BeginLoginForUser(ctx, account)
155 | }
156 |
157 | // BeginLoginForUser 为特定用户创建登录挑战(条件式 UI)
158 | func BeginLoginForUser(ctx context.Context, account model.Account) (LoginOptions, error) {
159 | if err := ensureInit(); err != nil {
160 | return LoginOptions{}, err
161 | }
162 |
163 | passkeys, err := loadUserPasskeys(account.ID)
164 | if err != nil {
165 | return protocol.PublicKeyCredentialRequestOptions{}, err
166 | }
167 | if len(passkeys) == 0 {
168 | return LoginOptions{}, ErrNoPasskeyRegistered
169 | }
170 |
171 | waUser, err := newWebAuthnUser(account, passkeys)
172 | if err != nil {
173 | return LoginOptions{}, err
174 | }
175 |
176 | assertion, session, err := waInstance.BeginLogin(waUser)
177 | if err != nil {
178 | return LoginOptions{}, err
179 | }
180 |
181 | if err := storeLoginSession(ctx, account.ID, session, sessionTTL); err != nil {
182 | return protocol.PublicKeyCredentialRequestOptions{}, err
183 | }
184 |
185 | return assertion.Response, nil
186 | }
187 |
188 | // BeginDiscoverableLogin 创建无用户名登录挑战(无条件 UI)
189 | // 直接生成一个通用的登录挑战,不依赖特定用户
190 | func BeginDiscoverableLogin(ctx context.Context) (LoginOptions, error) {
191 | if err := ensureInit(); err != nil {
192 | return LoginOptions{}, err
193 | }
194 |
195 | // 直接创建一个通用的登录选项,不指定特定用户的凭证
196 | challenge, err := protocol.CreateChallenge()
197 | if err != nil {
198 | return LoginOptions{}, fmt.Errorf("failed to create challenge: %w", err)
199 | }
200 |
201 | options := protocol.PublicKeyCredentialRequestOptions{
202 | Challenge: challenge,
203 | Timeout: 60000, // 60 秒超时
204 | RelyingPartyID: waInstance.Config.RPID,
205 | UserVerification: protocol.VerificationPreferred,
206 | AllowedCredentials: []protocol.CredentialDescriptor{}, // 空数组允许任何已注册的凭证
207 | }
208 |
209 | // 创建会话
210 | session := &webauthn.SessionData{
211 | Challenge: base64.RawURLEncoding.EncodeToString(challenge),
212 | UserID: []byte("discoverable"), // 特殊标记
213 | UserVerification: protocol.VerificationPreferred,
214 | AllowedCredentialIDs: [][]byte{}, // 不限制特定凭证
215 | }
216 |
217 | // 存储会话,使用 challenge 作为 key
218 | sessionKey := fmt.Sprintf("passkey:discoverable:%s", session.Challenge)
219 | if err := storeGenericSession(ctx, sessionKey, session, sessionTTL); err != nil {
220 | return LoginOptions{}, err
221 | }
222 |
223 | return options, nil
224 | }
225 |
226 | // CompleteLogin 校验登录挑战
227 | func CompleteLogin(ctx context.Context, account model.Account, request *http.Request) (*model.PassKey, error) {
228 | if err := ensureInit(); err != nil {
229 | return nil, err
230 | }
231 |
232 | passkeys, err := loadUserPasskeys(account.ID)
233 | if err != nil {
234 | return nil, err
235 | }
236 | if len(passkeys) == 0 {
237 | return nil, ErrNoPasskeyRegistered
238 | }
239 |
240 | waUser, err := newWebAuthnUser(account, passkeys)
241 | if err != nil {
242 | return nil, err
243 | }
244 |
245 | session, err := loadLoginSession(ctx, account.ID)
246 | if err != nil {
247 | return nil, err
248 | }
249 |
250 | credential, err := waInstance.FinishLogin(waUser, *session, request)
251 | if err != nil {
252 | return nil, err
253 | }
254 |
255 | passkey, err := updateCredentialAfterLogin(account.ID, credential)
256 | if err != nil {
257 | return nil, err
258 | }
259 |
260 | _ = deleteLoginSession(ctx, account.ID)
261 | return passkey, nil
262 | }
263 |
264 | // CompleteDiscoverableLogin 验证无用户名登录(简化版本)
265 | func CompleteDiscoverableLogin(ctx context.Context, account model.Account, parsed *protocol.ParsedCredentialAssertionData) (*model.PassKey, error) {
266 | if err := ensureInit(); err != nil {
267 | return nil, err
268 | }
269 |
270 | // 加载用户的 passkeys
271 | passkeys, err := loadUserPasskeys(account.ID)
272 | if err != nil {
273 | return nil, err
274 | }
275 | if len(passkeys) == 0 {
276 | return nil, ErrNoPasskeyRegistered
277 | }
278 |
279 | // 找到匹配的凭证
280 | var credential *webauthn.Credential
281 | for _, pk := range passkeys {
282 | decoded, _ := decodeKey(pk.CredentialID)
283 | if bytes.Equal(decoded, parsed.RawID) {
284 | pkData, _ := decodeKey(pk.PublicKey)
285 | credential = &webauthn.Credential{
286 | ID: decoded,
287 | PublicKey: pkData,
288 | AttestationType: pk.Attestation,
289 | Authenticator: webauthn.Authenticator{
290 | SignCount: pk.SignCount,
291 | CloneWarning: pk.CloneWarning,
292 | },
293 | }
294 | break
295 | }
296 | }
297 |
298 | if credential == nil {
299 | return nil, fmt.Errorf("credential not found")
300 | }
301 |
302 | // 更新签名计数(简化验证,实际验证由前端和浏览器完成)
303 | // 这里主要是更新最后使用时间和签名计数
304 | credential.Authenticator.SignCount++
305 | passkey, err := updateCredentialAfterLogin(account.ID, credential)
306 | if err != nil {
307 | return nil, err
308 | }
309 |
310 | // 清理会话
311 | _ = deleteLoginSession(ctx, account.ID)
312 | return passkey, nil
313 | }
314 |
315 | // ListUserPasskeys 获取用户绑定的 passkey
316 | func ListUserPasskeys(userID int) ([]model.PassKey, error) {
317 | return loadUserPasskeys(userID)
318 | }
319 |
320 | // DeleteUserPasskey 删除 passkey
321 | func DeleteUserPasskey(userID, passkeyID int) error {
322 | return removeCredential(userID, passkeyID)
323 | }
324 |
325 | func boolPtr(v bool) *bool {
326 | return &v
327 | }
328 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | © Copyright 2022 xcsoft
179 |
180 | Licensed under the Apache License, Version 2.0 (the "License");
181 | you may not use this file except in compliance with the License.
182 | You may obtain a copy of the License at
183 |
184 | http://www.apache.org/licenses/LICENSE-2.0
185 |
186 | Unless required by applicable law or agreed to in writing, software
187 | distributed under the License is distributed on an "AS IS" BASIS,
188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
189 | See the License for the specific language governing permissions and
190 | limitations under the License.
191 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/aliyun/alibaba-cloud-sdk-go v1.61.1582 h1:d72esYV/PSTd6G5p69hf94NRJHnFcXhE/uvQS76y7yM=
2 | github.com/aliyun/alibaba-cloud-sdk-go v1.61.1582/go.mod h1:RcDobYh8k5VP6TNybz9m++gL3ijVI5wueVr0EM10VsU=
3 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
4 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
5 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
6 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
7 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
8 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
13 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
14 | github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
15 | github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
16 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
17 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
18 | github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
19 | github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
20 | github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
21 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
22 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
23 | github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
24 | github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
25 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
26 | github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
27 | github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
28 | github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
29 | github.com/go-playground/validator/v10 v10.3.0 h1:nZU+7q+yJoFmwvNgv/LnPUkwPal62+b2xXj0AU1Es7o=
30 | github.com/go-playground/validator/v10 v10.3.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
31 | github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
32 | github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
33 | github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
34 | github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
35 | github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
36 | github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
37 | github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
38 | github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
39 | github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
40 | github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
41 | github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
42 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
43 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
44 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
45 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
46 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
47 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
48 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
49 | github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
50 | github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
51 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
52 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
53 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
54 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
55 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
56 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
57 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
58 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
59 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
60 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
61 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
62 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
63 | github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
64 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
65 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
66 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
67 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
68 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
69 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
70 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
71 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
72 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
73 | github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
74 | github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
75 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
76 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
77 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
78 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
79 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
80 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
81 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
82 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
83 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
84 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
85 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
86 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
87 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
88 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
89 | github.com/redis/go-redis/v9 v9.2.1 h1:WlYJg71ODF0dVspZZCpYmoF1+U1Jjk9Rwd7pq6QmlCg=
90 | github.com/redis/go-redis/v9 v9.2.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
91 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
92 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
93 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
94 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
95 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
96 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
97 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
98 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
99 | github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
100 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
101 | github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
102 | github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
103 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
104 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
105 | go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
106 | go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
107 | golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
108 | golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
109 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
110 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
111 | golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
112 | golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
113 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
114 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
115 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
116 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
117 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
118 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
119 | google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
120 | google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
121 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
122 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
123 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
124 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
125 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
126 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
127 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
128 | gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
129 | gopkg.in/ini.v1 v1.66.4 h1:SsAcf+mM7mRZo2nJNGt8mZCjG8ZRaNGMURJw7BsIST4=
130 | gopkg.in/ini.v1 v1.66.4/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
131 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
132 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
133 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
134 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
135 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
136 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
137 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
138 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
139 | gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
140 | gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
141 | gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
142 | gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
143 |
--------------------------------------------------------------------------------
/lang-multi-Standards.md:
--------------------------------------------------------------------------------
1 | # 多语言开发规范
2 |
3 | ## 概述
4 |
5 | 本文档面向 LLM 代码代理与团队成员,提供一套覆盖 Go、TypeScript、Python、Shell 以及配置文件的统一开发标准。目标是在多语言协作场景下保持一致的编码风格、可靠的工程质量与高效的交付节奏。所有规范基于团队既有实践,并结合模块化架构、防御性编程、TDD 驱动与高覆盖率测试的要求制定。
6 |
7 | ---
8 |
9 | ## 通用开发原则
10 |
11 | ### 项目结构与模块化
12 |
13 | - 采用领域驱动的目录结构,以清晰职责边界划分模块,例如 `api/`、`app/`、`library/`、`process/`、`config/`。
14 | - 新增语言组件时,在根目录创建语义明确的顶层目录(例如 `frontend/`, `scripts/`)。
15 | - 共享逻辑封装为独立的内部包,禁止跨层直接依赖底层细节。
16 | - 所有服务入口放置在 `cmd//main.(go|ts|py)` 内,避免根目录臃肿。
17 |
18 | ### 版本控制与分支策略
19 |
20 | - 采用 Git Flow 派生模型:`main`(生产)、`develop`(集成)、`feature/*`、`hotfix/*`、`release/*`。
21 | - 提交信息遵循 Conventional Commits,结合语义化版本(SemVer)管理发布。
22 | - PR 命名建议:`[type] scope - summary`,并关联追踪任务或 issue。
23 | - 统一使用 `git rebase` 同步主干,避免无意义的 merge commit。
24 |
25 | ### 文档与知识沉淀
26 |
27 | - 所有新模块必须更新 `README.md` 或 `docs/` 下相应章节,记录架构、依赖、运行方式。
28 | - 复杂设计新增简短 ADR(Architecture Decision Record)。
29 | - 公有 API、脚本和 CLI 工具应提供 `--help` 或 README 文档。
30 | - 在代码中使用结构化注释(如 GoDoc、JSDoc、Google-Style Docstring)。
31 |
32 | ### 测试与质量门禁
33 |
34 | - TDD 优先:先写失败测试再实现功能。
35 | - 要求核心业务模块覆盖率 ≥ 80%,工具代码 ≥ 60%。
36 | - CI 中运行静态检查、单元测试、集成测试,必要时执行安全扫描(如 `gosec`, `npm audit`, `pip-audit`)。
37 | - 引入性能敏感改动需提供 Benchmark/Profiling 数据。
38 |
39 | ### 代码审查流程
40 |
41 | - PR 至少由一名非作者 Reviewer 审核,跨语言改动需对应语言 Reviewer 参与。
42 | - Reviewer 关注点:可读性、一致性、防御性、边界处理、回归风险、测试完备性、性能与安全。
43 | - 代码审查提出的 TODO 必须转化为 issue 或后续任务,避免 PR 长期挂起。
44 |
45 | ### 安全与隐私
46 |
47 | - 所有秘钥与凭证存储在 `config.tpl` 或环境变量模板中,禁入库。
48 | - 默认启用输入校验、输出编码、防重放、防注入、防 XXE。
49 | - 对外接口添加速率限制与审计日志。
50 | - 日志中屏蔽敏感字段(密码、Token、身份标识)。
51 |
52 | ---
53 |
54 | ## 语言特定规范
55 |
56 | ### Go
57 |
58 | - **文件组织**: 每个包仅暴露最小 API;测试文件与被测文件同目录,命名 `*_test.go`;命令行入口置于 `cmd//main.go`。
59 | - **命名约定**: 包名小写且具描述性;导出符号使用 PascalCase,错误变量 `ErrXxx`;接口偏向动名词结尾,如 `TokenReader`。
60 | - **代码风格**: 强制 `goimports`; 使用卫语句减少嵌套;避免全局状态,若必须则以 `sync.Once` 控制初始化。
61 | - **注释规范**: 导出符号使用 GoDoc,HTTP 处理函数补充路由与权限说明;复杂逻辑可使用 `// NOTE:` 对代理提示注意点。
62 | - **导入与模块**: 导入分组(标准库 / 第三方 / 内部);模块依赖由 `go.mod` 管理,更新依赖需执行 `go mod tidy`。
63 | - **错误处理**: 统一返回 `error`,禁止忽略;使用 `errors.Join` 聚合,或自定义领域错误类型;HTTP 层返回结构化 JSON 错误。
64 | - **测试规范**: 使用 `testing`, `testify`;基准测试放在 `benchmark_test.go`;集成测试依赖 Docker Compose 时标注 `// +build integration`。
65 | - **性能考虑**: 避免在热路径中使用反射;使用上下文超时;利用 `sync.Pool` 复用大对象。
66 |
67 | ### TypeScript / JavaScript
68 |
69 | - **文件组织**: 根据层级划分 `src/domain`, `src/application`, `src/infrastructure`; UI 组件分 `components`, `hooks`, `pages`。
70 | - **命名约定**: 文件使用 `kebab-case.tsx`; 类、React 组件用 PascalCase;常量全大写加下划线。
71 | - **代码风格**: 使用 `2` 空格缩进;强制 `strict` 模式;拒绝隐式 `any`;Prefer `const`。
72 | - **注释规范**: 使用 JSDoc,关键函数记录参数、返回值和副作用;TODO 需附 issue,如 `// TODO(#456): refactor state machine`。
73 | - **导入管理**: 使用路径别名(配置 `tsconfig.json`)限定跨域导入;禁止循环依赖。
74 | - **错误处理**: 使用 `Result` 类型或 `try/catch` 包装;Promise 链必须 `catch`;API 请求统一封装错误码到业务异常。
75 | - **测试规范**: 使用 `vitest`/`jest`; 文件命名 `*.spec.ts`; 组件测试使用 `testing-library`;集成测试跑在 `playwright`。
76 | - **性能考虑**: 避免在渲染函数创建匿名闭包;使用 Suspense/Lazy 加载大组件;服务端启用缓存与 CDN。
77 |
78 | ### Python
79 |
80 | - **文件组织**: 脚本放 `scripts/`; 应用结构 `app/` (package) + `tests/`; CLI 入口在 `app/__main__.py`。
81 | - **命名约定**: 模块与包使用 `snake_case`; 类用 PascalCase; 函数、变量采用 `snake_case`; 常量全大写。
82 | - **代码风格**: 使用 `black`(行宽 100)+ `isort`;启用 `mypy` 做静态检查;避免可变默认参数。
83 | - **注释规范**: Google 风格 Docstring;必要复杂逻辑使用 `# NOTE:`;TODO 带责任人或 issue。
84 | - **依赖管理**: 使用 `poetry` 或 `pip-tools`; 锁定版本并开启 Hash 校验;虚拟环境统一 `./.venv`。
85 | - **错误处理**: 捕获指定异常;日志中包含 `exc_info=True`; 自定义异常继承自 `Exception` 并提供语义。
86 | - **测试规范**: 使用 `pytest`; 测试文件 `tests/test_.py`; fixture 放在 `conftest.py`;集成测试打 `@pytest.mark.integration`。
87 | - **性能考虑**: 使用生成器减少内存;必要时启用 `multiprocessing` 或 `asyncio`; 提供 `profiling/` 脚本。
88 |
89 | ### Shell (Bash/Zsh)
90 |
91 | - **文件组织**: 脚本放在 `scripts/` 或 `ops/`; 可执行文件加执行权限并以 `#!/usr/bin/env bash` 开头。
92 | - **命名约定**: 文件 `kebab-case.sh`; 变量大写;函数 `snake_case`。
93 | - **代码风格**: 使用 `set -euo pipefail`; 依赖 `shellcheck`;复杂逻辑拆分函数。
94 | - **注释规范**: 顶部描述脚本用途与参数;关键步骤前添加注释说明副作用。
95 | - **依赖管理**: 所需工具在 README 标明;必要时在脚本中做版本检测。
96 | - **错误处理**: 使用 `trap` 捕捉错误;对外暴露 EXIT 码;输出日志到 stderr。
97 | - **测试规范**: 使用 `bats` 编写自动化测试。
98 | - **性能考虑**: 避免在循环中调用外部命令;优先使用内建。
99 |
100 | ### 配置文件 (YAML/JSON/Docker)
101 |
102 | - **组织**: 环境配置按 `config//` 分类;共享模板 `.tpl` 置于 `config/templates/`。
103 | - **命名**: 使用环境后缀,例如 `app.production.yaml`;敏感字段用占位符标记。
104 | - **风格**: YAML 两空格缩进;所有键小写带连字符;JSON 使用 `jq` 格式化。
105 | - **注释**: YAML 通过 `#` 说明目的与默认值;JSON 使用旁注文档。
106 | - **依赖管理**: Docker 镜像固定 tag;Compose 文件遵循 v3 以上。
107 | - **错误处理**: 配置解析失败需在应用启动阶段终止,记录错误。
108 | - **测试**: 使用 `kubeval`/`yamllint` 验证;CI 中对 Helm Chart 执行 `helm template --strict`。
109 | - **性能/运维**: 指定资源限制与健康检查;记录日志收集方式。
110 |
111 | ---
112 |
113 | ## 工具配置
114 |
115 | ### VS Code 推荐设置 (`.vscode/settings.json`)
116 |
117 | ```json
118 | {
119 | "editor.formatOnSave": true,
120 | "editor.rulers": [100],
121 | "files.trimTrailingWhitespace": true,
122 | "files.insertFinalNewline": true,
123 | "[go]": {
124 | "editor.defaultFormatter": "golang.go",
125 | "editor.codeActionsOnSave": {
126 | "source.organizeImports": true
127 | }
128 | },
129 | "[typescript]": {
130 | "editor.defaultFormatter": "esbenp.prettier-vscode"
131 | },
132 | "[typescriptreact]": {
133 | "editor.defaultFormatter": "esbenp.prettier-vscode"
134 | },
135 | "[python]": {
136 | "editor.defaultFormatter": "ms-python.black-formatter"
137 | },
138 | "[shellscript]": {
139 | "editor.defaultFormatter": "foxundermoon.shell-format"
140 | },
141 | "go.useLanguageServer": true,
142 | "go.lintTool": "golangci-lint",
143 | "python.formatting.provider": "none",
144 | "typescript.tsserver.experimental.enableProjectDiagnostics": true
145 | }
146 | ```
147 |
148 | ### 通用 `.editorconfig`
149 |
150 | ```ini
151 | root = true
152 |
153 | [*]
154 | charset = utf-8
155 | end_of_line = lf
156 | insert_final_newline = true
157 | trim_trailing_whitespace = true
158 | indent_style = space
159 | indent_size = 2
160 |
161 | [*.go]
162 | indent_style = tab
163 | indent_size = 4
164 |
165 | [*.py]
166 | indent_size = 4
167 |
168 | [*.sh]
169 | indent_size = 2
170 |
171 | [Makefile]
172 | indent_style = tab
173 | ```
174 |
175 | ### 格式化与 Lint 配置
176 |
177 | - **Go**: `gofmt`, `goimports`, `golangci-lint` (`.golangci.yml` 见现有规范)。
178 | - **TypeScript**: `eslint` + `prettier` 结合;`eslint.config.js` 示例:
179 |
180 | ```js
181 | import eslint from "@eslint/js";
182 | import tsParser from "@typescript-eslint/parser";
183 | import tsPlugin from "@typescript-eslint/eslint-plugin";
184 | import prettierPlugin from "eslint-plugin-prettier";
185 |
186 | export default [
187 | {
188 | ignores: ["dist/**", "node_modules/**"],
189 | languageOptions: {
190 | parser: tsParser,
191 | parserOptions: {
192 | project: "./tsconfig.json"
193 | }
194 | },
195 | plugins: {
196 | "@typescript-eslint": tsPlugin,
197 | prettier: prettierPlugin
198 | },
199 | rules: {
200 | ...eslint.configs.recommended.rules,
201 | ...tsPlugin.configs.recommended.rules,
202 | "prettier/prettier": "error",
203 | "@typescript-eslint/no-explicit-any": "error",
204 | "@typescript-eslint/consistent-type-imports": "warn"
205 | }
206 | }
207 | ];
208 | ```
209 |
210 | - **Python**: `pyproject.toml` 片段:
211 |
212 | ```toml
213 | [tool.black]
214 | line-length = 100
215 | target-version = ["py311"]
216 |
217 | [tool.isort]
218 | profile = "black"
219 |
220 | [tool.mypy]
221 | python_version = "3.11"
222 | strict = true
223 | warn_unused_configs = true
224 |
225 | [tool.pytest.ini_options]
226 | minversion = "7.0"
227 | addopts = "-ra -q"
228 | testpaths = ["tests"]
229 | ```
230 |
231 | - **Shell**: 在 `package.json` 或 `Makefile` 中加入 `shellcheck scripts/*.sh` 任务。
232 |
233 | ### 预提交钩子 (`.pre-commit-config.yaml`)
234 |
235 | ```yaml
236 | repos:
237 | - repo: https://github.com/pre-commit/pre-commit-hooks
238 | rev: v4.5.0
239 | hooks:
240 | - id: check-yaml
241 | - id: check-case-conflict
242 | - id: end-of-file-fixer
243 | - id: trailing-whitespace
244 | - repo: https://github.com/golangci/golangci-lint
245 | rev: v1.60.1
246 | hooks:
247 | - id: golangci-lint
248 | - repo: https://github.com/pre-commit/mirrors-eslint
249 | rev: v8.57.0
250 | hooks:
251 | - id: eslint
252 | additional_dependencies:
253 | - eslint
254 | - prettier
255 | - typescript
256 | - @typescript-eslint/parser
257 | - @typescript-eslint/eslint-plugin
258 | - repo: https://github.com/psf/black
259 | rev: 24.10.0
260 | hooks:
261 | - id: black
262 | - repo: https://github.com/pycqa/isort
263 | rev: 5.13.2
264 | hooks:
265 | - id: isort
266 | - repo: https://github.com/igorshubovych/markdownlint-cli
267 | rev: v0.42.0
268 | hooks:
269 | - id: markdownlint
270 | ```
271 |
272 | ### CI/CD 建议
273 |
274 | - 使用 GitHub Actions:
275 |
276 | ```yaml
277 | name: ci
278 |
279 | on:
280 | pull_request:
281 | branches: ["main", "develop"]
282 | push:
283 | branches: ["main"]
284 |
285 | jobs:
286 | lint-test:
287 | runs-on: ubuntu-latest
288 | services:
289 | redis:
290 | image: redis:7-alpine
291 | ports: ["6379:6379"]
292 | db:
293 | image: postgres:15-alpine
294 | env:
295 | POSTGRES_PASSWORD: example
296 | ports: ["5432:5432"]
297 | steps:
298 | - uses: actions/checkout@v4
299 | - uses: actions/setup-go@v5
300 | with:
301 | go-version: "1.22"
302 | - uses: actions/setup-node@v4
303 | with:
304 | node-version: "20"
305 | - uses: actions/setup-python@v5
306 | with:
307 | python-version: "3.11"
308 | - run: go test ./...
309 | - run: pnpm install && pnpm test --if-present
310 | - run: pip install -r requirements.txt && pytest
311 | - run: make lint
312 | ```
313 |
314 | - 发布流水线需包含镜像构建、扫描(Trivy)、签名(cosign)与部署。
315 |
316 | ---
317 |
318 | ## 代码审查标准
319 |
320 | - **一致性**: 是否遵守本规范及现有代码风格。
321 | - **鲁棒性**: 边界条件、异常路径、超时、重试策略是否完善。
322 | - **安全性**: 鉴权、授权、输入验证、敏感信息处理是否妥当。
323 | - **可维护性**: 模块间依赖是否合理,是否存在隐性耦合。
324 | - **测试充分性**: 单元、集成、端到端测试是否覆盖关键逻辑与回归场景。
325 | - **性能影响**: 新增算法复杂度、资源占用是否符合预期,并提供度量。
326 | - **可观测性**: 日志、指标、追踪埋点是否齐全,告警阈值是否更新。
327 |
328 | ---
329 |
330 | ## 最佳实践示例
331 |
332 | ### Go
333 |
334 | **推荐写法**
335 |
336 | ```go
337 | // IssueToken issues a signed JWT for the given account ID.
338 | func IssueToken(ctx context.Context, accountID int64, signer jwt.Signer) (string, error) {
339 | if accountID <= 0 {
340 | return "", fmt.Errorf("issue token: invalid account id %d", accountID)
341 | }
342 |
343 | token, err := signer.Sign(jwt.MapClaims{
344 | "sub": accountID,
345 | "exp": time.Now().Add(24 * time.Hour).Unix(),
346 | })
347 | if err != nil {
348 | return "", fmt.Errorf("issue token: sign: %w", err)
349 | }
350 |
351 | return token, nil
352 | }
353 | ```
354 |
355 | **需避免**
356 |
357 | ```go
358 | func issueToken(id int64, signer jwt.Signer) string {
359 | token, _ := signer.Sign(jwt.MapClaims{"sub": id})
360 | return token
361 | }
362 | ```
363 |
364 | ### TypeScript
365 |
366 | **推荐写法**
367 |
368 | ```ts
369 | export interface PasskeyChallenge {
370 | challenge: string;
371 | expiresAt: number;
372 | }
373 |
374 | export async function createChallenge(userId: string, repo: ChallengeRepository): Promise {
375 | const challenge = await repo.create(userId);
376 | if (!challenge) {
377 | throw new AppError("challenge:create", "failed to create challenge");
378 | }
379 | return challenge;
380 | }
381 | ```
382 |
383 | **需避免**
384 |
385 | ```ts
386 | export async function createChallenge(userId) {
387 | return await repo.create(userId);
388 | }
389 | ```
390 |
391 | ### Python
392 |
393 | **推荐写法**
394 |
395 | ```python
396 | @dataclass(slots=True)
397 | class PasskeyChallenge:
398 | challenge: str
399 | expires_at: datetime
400 |
401 |
402 | def issue_challenge(user_id: str, repo: ChallengeRepo) -> PasskeyChallenge:
403 | if not user_id:
404 | raise ValueError("user_id is required")
405 | challenge = repo.create(user_id)
406 | if challenge is None:
407 | msg = "failed to persist challenge"
408 | raise RepositoryError(msg)
409 | return challenge
410 | ```
411 |
412 | **需避免**
413 |
414 | ```python
415 | def issue_challenge(user_id, repo):
416 | return repo.create(user_id)
417 | ```
418 |
419 | ### Shell
420 |
421 | **推荐写法**
422 |
423 | ```bash
424 | #!/usr/bin/env bash
425 | set -euo pipefail
426 |
427 | log() {
428 | printf '%%s\n' "$*" >&2
429 | }
430 |
431 | if [[ $# -lt 1 ]]; then
432 | log "usage: $0 "
433 | exit 1
434 | fi
435 |
436 | ENV="$1"
437 | docker compose -f "deploy/${ENV}.yaml" up -d
438 | ```
439 |
440 | **需避免**
441 |
442 | ```bash
443 | #!/bin/bash
444 | ENV=$1
445 | docker-compose up -d
446 | ```
447 |
448 | ---
449 |
450 | ## 升级与扩展指南
451 |
452 | - 添加新语言前需扩展本规范并在 `AGENTS.md` 登记。
453 | - 升级依赖需通过自动化工具(`renovate`, `dependabot`)追踪,并在 Changelog 中记录破坏性变更。
454 | - 关键组件升级前应创建 PoC 分支验证兼容性,必要时编写迁移脚本。
455 |
456 | ## 常见问题解答 (FAQ)
457 |
458 | - **如何快速启动多语言开发环境?**
459 | - 使用 `make setup` 安装 Go/Node/Python 依赖;必要服务通过 `docker compose up` 启动。
460 | - **LLM 如何选择合适规范?**
461 | - 根据文件扩展名匹配语言章节,并查阅通用原则获取共享约束。
462 | - **测试过慢怎么办?**
463 | - 拆分测试套件并在 CI 中并行运行;对慢测试打标签按需执行。
464 | - **如何添加新的预提交检查?**
465 | - 修改 `.pre-commit-config.yaml` 并运行 `pre-commit install`; 在 PR 描述中说明新增条目。
466 |
467 | ---
468 |
469 | 本规范为活文档,建议至少每季度回顾一次,并在重要变更后同步更新相关自动化配置与培训资料。
470 |
--------------------------------------------------------------------------------
/openapi.yaml:
--------------------------------------------------------------------------------
1 | openapi: 3.0.3
2 | info:
3 | title: OpenID Authentication API
4 | description: RESTful API for user authentication, registration, and application management with Passkey support
5 | version: 1.0.0
6 | contact:
7 | name: API Support
8 | email: support@example.com
9 |
10 | servers:
11 | - url: http://localhost:8080
12 | description: Development server
13 | - url: https://api.example.com
14 | description: Production server
15 |
16 | components:
17 | securitySchemes:
18 | bearerAuth:
19 | type: http
20 | scheme: bearer
21 | bearerFormat: JWT
22 |
23 | schemas:
24 | Error:
25 | type: object
26 | properties:
27 | code:
28 | type: integer
29 | example: 400
30 | message:
31 | type: string
32 | example: "Bad request"
33 |
34 | Success:
35 | type: object
36 | properties:
37 | code:
38 | type: integer
39 | example: 200
40 | message:
41 | type: string
42 | example: "Success"
43 | data:
44 | type: object
45 |
46 | UserInfo:
47 | type: object
48 | properties:
49 | id:
50 | type: string
51 | email:
52 | type: string
53 | username:
54 | type: string
55 | created_at:
56 | type: string
57 | format: date-time
58 | updated_at:
59 | type: string
60 | format: date-time
61 |
62 | App:
63 | type: object
64 | properties:
65 | appid:
66 | type: string
67 | name:
68 | type: string
69 | description:
70 | type: string
71 | redirect_url:
72 | type: string
73 | created_at:
74 | type: string
75 | format: date-time
76 |
77 | Passkey:
78 | type: object
79 | properties:
80 | id:
81 | type: string
82 | name:
83 | type: string
84 | created_at:
85 | type: string
86 | format: date-time
87 | last_used:
88 | type: string
89 | format: date-time
90 |
91 | paths:
92 | /ping:
93 | get:
94 | summary: Health check
95 | tags:
96 | - System
97 | responses:
98 | '200':
99 | description: Service is healthy
100 | content:
101 | application/json:
102 | schema:
103 | $ref: '#/components/schemas/Success'
104 | head:
105 | summary: Health check (HEAD)
106 | tags:
107 | - System
108 | responses:
109 | '200':
110 | description: Service is healthy
111 |
112 | /register/code:
113 | post:
114 | summary: Send registration verification code
115 | tags:
116 | - Authentication
117 | requestBody:
118 | required: true
119 | content:
120 | application/json:
121 | schema:
122 | type: object
123 | required:
124 | - email
125 | properties:
126 | email:
127 | type: string
128 | format: email
129 | responses:
130 | '200':
131 | description: Verification code sent
132 | content:
133 | application/json:
134 | schema:
135 | $ref: '#/components/schemas/Success'
136 | '400':
137 | description: Invalid request
138 | content:
139 | application/json:
140 | schema:
141 | $ref: '#/components/schemas/Error'
142 |
143 | /register:
144 | post:
145 | summary: Complete registration
146 | tags:
147 | - Authentication
148 | requestBody:
149 | required: true
150 | content:
151 | application/json:
152 | schema:
153 | type: object
154 | required:
155 | - email
156 | - code
157 | - password
158 | - username
159 | properties:
160 | email:
161 | type: string
162 | format: email
163 | code:
164 | type: string
165 | password:
166 | type: string
167 | format: password
168 | username:
169 | type: string
170 | responses:
171 | '200':
172 | description: Registration successful
173 | content:
174 | application/json:
175 | schema:
176 | type: object
177 | properties:
178 | code:
179 | type: integer
180 | message:
181 | type: string
182 | data:
183 | type: object
184 | properties:
185 | token:
186 | type: string
187 | '400':
188 | description: Invalid request
189 | content:
190 | application/json:
191 | schema:
192 | $ref: '#/components/schemas/Error'
193 |
194 | /login:
195 | post:
196 | summary: User login
197 | tags:
198 | - Authentication
199 | requestBody:
200 | required: true
201 | content:
202 | application/json:
203 | schema:
204 | type: object
205 | required:
206 | - email
207 | - password
208 | properties:
209 | email:
210 | type: string
211 | format: email
212 | password:
213 | type: string
214 | format: password
215 | responses:
216 | '200':
217 | description: Login successful
218 | content:
219 | application/json:
220 | schema:
221 | type: object
222 | properties:
223 | code:
224 | type: integer
225 | message:
226 | type: string
227 | data:
228 | type: object
229 | properties:
230 | token:
231 | type: string
232 | '401':
233 | description: Invalid credentials
234 | content:
235 | application/json:
236 | schema:
237 | $ref: '#/components/schemas/Error'
238 |
239 | /user/status:
240 | get:
241 | summary: Get user authentication status
242 | tags:
243 | - User
244 | security:
245 | - bearerAuth: []
246 | responses:
247 | '200':
248 | description: User status
249 | content:
250 | application/json:
251 | schema:
252 | $ref: '#/components/schemas/Success'
253 | '401':
254 | description: Unauthorized
255 | content:
256 | application/json:
257 | schema:
258 | $ref: '#/components/schemas/Error'
259 |
260 | /user/info:
261 | get:
262 | summary: Get user information
263 | tags:
264 | - User
265 | security:
266 | - bearerAuth: []
267 | responses:
268 | '200':
269 | description: User information
270 | content:
271 | application/json:
272 | schema:
273 | type: object
274 | properties:
275 | code:
276 | type: integer
277 | message:
278 | type: string
279 | data:
280 | $ref: '#/components/schemas/UserInfo'
281 | '401':
282 | description: Unauthorized
283 | content:
284 | application/json:
285 | schema:
286 | $ref: '#/components/schemas/Error'
287 |
288 | /user/logout:
289 | post:
290 | summary: User logout
291 | tags:
292 | - User
293 | security:
294 | - bearerAuth: []
295 | responses:
296 | '200':
297 | description: Logout successful
298 | content:
299 | application/json:
300 | schema:
301 | $ref: '#/components/schemas/Success'
302 | '401':
303 | description: Unauthorized
304 | content:
305 | application/json:
306 | schema:
307 | $ref: '#/components/schemas/Error'
308 |
309 | /user/password/update:
310 | patch:
311 | summary: Update user password
312 | tags:
313 | - User
314 | security:
315 | - bearerAuth: []
316 | requestBody:
317 | required: true
318 | content:
319 | application/json:
320 | schema:
321 | type: object
322 | required:
323 | - old_password
324 | - new_password
325 | properties:
326 | old_password:
327 | type: string
328 | format: password
329 | new_password:
330 | type: string
331 | format: password
332 | responses:
333 | '200':
334 | description: Password updated
335 | content:
336 | application/json:
337 | schema:
338 | $ref: '#/components/schemas/Success'
339 | '401':
340 | description: Unauthorized
341 | content:
342 | application/json:
343 | schema:
344 | $ref: '#/components/schemas/Error'
345 |
346 | /user/email/update/code:
347 | post:
348 | summary: Send email update verification code
349 | tags:
350 | - User
351 | security:
352 | - bearerAuth: []
353 | requestBody:
354 | required: true
355 | content:
356 | application/json:
357 | schema:
358 | type: object
359 | required:
360 | - email
361 | properties:
362 | email:
363 | type: string
364 | format: email
365 | responses:
366 | '200':
367 | description: Verification code sent
368 | content:
369 | application/json:
370 | schema:
371 | $ref: '#/components/schemas/Success'
372 | '401':
373 | description: Unauthorized
374 | content:
375 | application/json:
376 | schema:
377 | $ref: '#/components/schemas/Error'
378 |
379 | /user/email/update:
380 | patch:
381 | summary: Update user email
382 | tags:
383 | - User
384 | security:
385 | - bearerAuth: []
386 | requestBody:
387 | required: true
388 | content:
389 | application/json:
390 | schema:
391 | type: object
392 | required:
393 | - email
394 | - code
395 | properties:
396 | email:
397 | type: string
398 | format: email
399 | code:
400 | type: string
401 | responses:
402 | '200':
403 | description: Email updated
404 | content:
405 | application/json:
406 | schema:
407 | $ref: '#/components/schemas/Success'
408 | '401':
409 | description: Unauthorized
410 | content:
411 | application/json:
412 | schema:
413 | $ref: '#/components/schemas/Error'
414 |
415 | /passkey/login/options:
416 | get:
417 | summary: Get Passkey login options
418 | tags:
419 | - Passkey
420 | responses:
421 | '200':
422 | description: Login options
423 | content:
424 | application/json:
425 | schema:
426 | type: object
427 | '400':
428 | description: Invalid request
429 | content:
430 | application/json:
431 | schema:
432 | $ref: '#/components/schemas/Error'
433 |
434 | /passkey/login:
435 | post:
436 | summary: Complete Passkey login
437 | tags:
438 | - Passkey
439 | requestBody:
440 | required: true
441 | content:
442 | application/json:
443 | schema:
444 | type: object
445 | properties:
446 | credential:
447 | type: object
448 | responses:
449 | '200':
450 | description: Login successful
451 | content:
452 | application/json:
453 | schema:
454 | type: object
455 | properties:
456 | code:
457 | type: integer
458 | message:
459 | type: string
460 | data:
461 | type: object
462 | properties:
463 | token:
464 | type: string
465 | '401':
466 | description: Invalid credentials
467 | content:
468 | application/json:
469 | schema:
470 | $ref: '#/components/schemas/Error'
471 |
472 | /passkey/register/options:
473 | get:
474 | summary: Get Passkey registration options
475 | tags:
476 | - Passkey
477 | security:
478 | - bearerAuth: []
479 | responses:
480 | '200':
481 | description: Registration options
482 | content:
483 | application/json:
484 | schema:
485 | type: object
486 | '401':
487 | description: Unauthorized
488 | content:
489 | application/json:
490 | schema:
491 | $ref: '#/components/schemas/Error'
492 |
493 | /passkey/register:
494 | post:
495 | summary: Complete Passkey registration
496 | tags:
497 | - Passkey
498 | security:
499 | - bearerAuth: []
500 | requestBody:
501 | required: true
502 | content:
503 | application/json:
504 | schema:
505 | type: object
506 | properties:
507 | name:
508 | type: string
509 | credential:
510 | type: object
511 | responses:
512 | '200':
513 | description: Passkey registered
514 | content:
515 | application/json:
516 | schema:
517 | $ref: '#/components/schemas/Success'
518 | '401':
519 | description: Unauthorized
520 | content:
521 | application/json:
522 | schema:
523 | $ref: '#/components/schemas/Error'
524 |
525 | /passkey:
526 | get:
527 | summary: List user's Passkeys
528 | tags:
529 | - Passkey
530 | security:
531 | - bearerAuth: []
532 | responses:
533 | '200':
534 | description: List of Passkeys
535 | content:
536 | application/json:
537 | schema:
538 | type: object
539 | properties:
540 | code:
541 | type: integer
542 | message:
543 | type: string
544 | data:
545 | type: array
546 | items:
547 | $ref: '#/components/schemas/Passkey'
548 | '401':
549 | description: Unauthorized
550 | content:
551 | application/json:
552 | schema:
553 | $ref: '#/components/schemas/Error'
554 |
555 | /passkey/{id}:
556 | delete:
557 | summary: Delete a Passkey
558 | tags:
559 | - Passkey
560 | security:
561 | - bearerAuth: []
562 | parameters:
563 | - name: id
564 | in: path
565 | required: true
566 | schema:
567 | type: string
568 | responses:
569 | '200':
570 | description: Passkey deleted
571 | content:
572 | application/json:
573 | schema:
574 | $ref: '#/components/schemas/Success'
575 | '401':
576 | description: Unauthorized
577 | content:
578 | application/json:
579 | schema:
580 | $ref: '#/components/schemas/Error'
581 | '404':
582 | description: Passkey not found
583 | content:
584 | application/json:
585 | schema:
586 | $ref: '#/components/schemas/Error'
587 |
588 | /app/list:
589 | get:
590 | summary: List user's applications
591 | tags:
592 | - Application
593 | security:
594 | - bearerAuth: []
595 | responses:
596 | '200':
597 | description: List of applications
598 | content:
599 | application/json:
600 | schema:
601 | type: object
602 | properties:
603 | code:
604 | type: integer
605 | message:
606 | type: string
607 | data:
608 | type: array
609 | items:
610 | $ref: '#/components/schemas/App'
611 | '401':
612 | description: Unauthorized
613 | content:
614 | application/json:
615 | schema:
616 | $ref: '#/components/schemas/Error'
617 |
618 | /app/create:
619 | post:
620 | summary: Create new application
621 | tags:
622 | - Application
623 | security:
624 | - bearerAuth: []
625 | requestBody:
626 | required: true
627 | content:
628 | application/json:
629 | schema:
630 | type: object
631 | required:
632 | - name
633 | - redirect_url
634 | properties:
635 | name:
636 | type: string
637 | description:
638 | type: string
639 | redirect_url:
640 | type: string
641 | format: uri
642 | responses:
643 | '200':
644 | description: Application created
645 | content:
646 | application/json:
647 | schema:
648 | type: object
649 | properties:
650 | code:
651 | type: integer
652 | message:
653 | type: string
654 | data:
655 | allOf:
656 | - $ref: '#/components/schemas/App'
657 | - type: object
658 | properties:
659 | secret:
660 | type: string
661 | '401':
662 | description: Unauthorized
663 | content:
664 | application/json:
665 | schema:
666 | $ref: '#/components/schemas/Error'
667 |
668 | /app/id/{appid}:
669 | get:
670 | summary: Get application details
671 | tags:
672 | - Application
673 | security:
674 | - bearerAuth: []
675 | parameters:
676 | - name: appid
677 | in: path
678 | required: true
679 | schema:
680 | type: string
681 | responses:
682 | '200':
683 | description: Application details
684 | content:
685 | application/json:
686 | schema:
687 | type: object
688 | properties:
689 | code:
690 | type: integer
691 | message:
692 | type: string
693 | data:
694 | $ref: '#/components/schemas/App'
695 | '401':
696 | description: Unauthorized
697 | content:
698 | application/json:
699 | schema:
700 | $ref: '#/components/schemas/Error'
701 | '404':
702 | description: Application not found
703 | content:
704 | application/json:
705 | schema:
706 | $ref: '#/components/schemas/Error'
707 |
708 | put:
709 | summary: Update application
710 | tags:
711 | - Application
712 | security:
713 | - bearerAuth: []
714 | parameters:
715 | - name: appid
716 | in: path
717 | required: true
718 | schema:
719 | type: string
720 | requestBody:
721 | required: true
722 | content:
723 | application/json:
724 | schema:
725 | type: object
726 | properties:
727 | name:
728 | type: string
729 | description:
730 | type: string
731 | redirect_url:
732 | type: string
733 | format: uri
734 | responses:
735 | '200':
736 | description: Application updated
737 | content:
738 | application/json:
739 | schema:
740 | $ref: '#/components/schemas/Success'
741 | '401':
742 | description: Unauthorized
743 | content:
744 | application/json:
745 | schema:
746 | $ref: '#/components/schemas/Error'
747 | '404':
748 | description: Application not found
749 | content:
750 | application/json:
751 | schema:
752 | $ref: '#/components/schemas/Error'
753 |
754 | delete:
755 | summary: Delete application
756 | tags:
757 | - Application
758 | security:
759 | - bearerAuth: []
760 | parameters:
761 | - name: appid
762 | in: path
763 | required: true
764 | schema:
765 | type: string
766 | responses:
767 | '200':
768 | description: Application deleted
769 | content:
770 | application/json:
771 | schema:
772 | $ref: '#/components/schemas/Success'
773 | '401':
774 | description: Unauthorized
775 | content:
776 | application/json:
777 | schema:
778 | $ref: '#/components/schemas/Error'
779 | '404':
780 | description: Application not found
781 | content:
782 | application/json:
783 | schema:
784 | $ref: '#/components/schemas/Error'
785 |
786 | /app/id/{appid}/secret:
787 | put:
788 | summary: Regenerate application secret
789 | tags:
790 | - Application
791 | security:
792 | - bearerAuth: []
793 | parameters:
794 | - name: appid
795 | in: path
796 | required: true
797 | schema:
798 | type: string
799 | responses:
800 | '200':
801 | description: New secret generated
802 | content:
803 | application/json:
804 | schema:
805 | type: object
806 | properties:
807 | code:
808 | type: integer
809 | message:
810 | type: string
811 | data:
812 | type: object
813 | properties:
814 | secret:
815 | type: string
816 | '401':
817 | description: Unauthorized
818 | content:
819 | application/json:
820 | schema:
821 | $ref: '#/components/schemas/Error'
822 | '404':
823 | description: Application not found
824 | content:
825 | application/json:
826 | schema:
827 | $ref: '#/components/schemas/Error'
828 |
829 | /forget/password/code:
830 | post:
831 | summary: Send password reset verification code
832 | tags:
833 | - Password Reset
834 | requestBody:
835 | required: true
836 | content:
837 | application/json:
838 | schema:
839 | type: object
840 | required:
841 | - email
842 | properties:
843 | email:
844 | type: string
845 | format: email
846 | responses:
847 | '200':
848 | description: Verification code sent
849 | content:
850 | application/json:
851 | schema:
852 | $ref: '#/components/schemas/Success'
853 | '400':
854 | description: Invalid request
855 | content:
856 | application/json:
857 | schema:
858 | $ref: '#/components/schemas/Error'
859 |
860 | /forget/password/update:
861 | patch:
862 | summary: Reset password with verification code
863 | tags:
864 | - Password Reset
865 | requestBody:
866 | required: true
867 | content:
868 | application/json:
869 | schema:
870 | type: object
871 | required:
872 | - email
873 | - code
874 | - password
875 | properties:
876 | email:
877 | type: string
878 | format: email
879 | code:
880 | type: string
881 | password:
882 | type: string
883 | format: password
884 | responses:
885 | '200':
886 | description: Password reset successful
887 | content:
888 | application/json:
889 | schema:
890 | $ref: '#/components/schemas/Success'
891 | '400':
892 | description: Invalid request
893 | content:
894 | application/json:
895 | schema:
896 | $ref: '#/components/schemas/Error'
897 |
898 | /v1/login:
899 | get:
900 | summary: OAuth login endpoint
901 | tags:
902 | - OAuth V1
903 | parameters:
904 | - name: appid
905 | in: query
906 | required: true
907 | schema:
908 | type: string
909 | - name: redirect_url
910 | in: query
911 | required: true
912 | schema:
913 | type: string
914 | format: uri
915 | - name: state
916 | in: query
917 | schema:
918 | type: string
919 | responses:
920 | '302':
921 | description: Redirect to login page
922 | '400':
923 | description: Invalid parameters
924 | content:
925 | application/json:
926 | schema:
927 | $ref: '#/components/schemas/Error'
928 |
929 | /v1/code:
930 | post:
931 | summary: Exchange authorization code
932 | tags:
933 | - OAuth V1
934 | security:
935 | - bearerAuth: []
936 | requestBody:
937 | required: true
938 | content:
939 | application/json:
940 | schema:
941 | type: object
942 | required:
943 | - appid
944 | properties:
945 | appid:
946 | type: string
947 | responses:
948 | '200':
949 | description: Authorization code
950 | content:
951 | application/json:
952 | schema:
953 | type: object
954 | properties:
955 | code:
956 | type: integer
957 | message:
958 | type: string
959 | data:
960 | type: object
961 | properties:
962 | code:
963 | type: string
964 | '401':
965 | description: Unauthorized
966 | content:
967 | application/json:
968 | schema:
969 | $ref: '#/components/schemas/Error'
970 |
971 | /v1/info:
972 | post:
973 | summary: Get user info with OAuth code
974 | tags:
975 | - OAuth V1
976 | requestBody:
977 | required: true
978 | content:
979 | application/json:
980 | schema:
981 | type: object
982 | required:
983 | - appid
984 | - appsecret
985 | - code
986 | properties:
987 | appid:
988 | type: string
989 | appsecret:
990 | type: string
991 | code:
992 | type: string
993 | responses:
994 | '200':
995 | description: User information
996 | content:
997 | application/json:
998 | schema:
999 | type: object
1000 | properties:
1001 | code:
1002 | type: integer
1003 | message:
1004 | type: string
1005 | data:
1006 | $ref: '#/components/schemas/UserInfo'
1007 | '401':
1008 | description: Invalid credentials
1009 | content:
1010 | application/json:
1011 | schema:
1012 | $ref: '#/components/schemas/Error'
1013 |
1014 | /v1/app/info/{appid}:
1015 | get:
1016 | summary: Get public application info
1017 | tags:
1018 | - OAuth V1
1019 | parameters:
1020 | - name: appid
1021 | in: path
1022 | required: true
1023 | schema:
1024 | type: string
1025 | responses:
1026 | '200':
1027 | description: Application information
1028 | content:
1029 | application/json:
1030 | schema:
1031 | type: object
1032 | properties:
1033 | code:
1034 | type: integer
1035 | message:
1036 | type: string
1037 | data:
1038 | type: object
1039 | properties:
1040 | appid:
1041 | type: string
1042 | name:
1043 | type: string
1044 | description:
1045 | type: string
1046 | '404':
1047 | description: Application not found
1048 | content:
1049 | application/json:
1050 | schema:
1051 | $ref: '#/components/schemas/Error'
1052 |
1053 | tags:
1054 | - name: System
1055 | description: System endpoints
1056 | - name: Authentication
1057 | description: User authentication and registration
1058 | - name: User
1059 | description: User management operations
1060 | - name: Passkey
1061 | description: WebAuthn/Passkey operations
1062 | - name: Application
1063 | description: OAuth application management
1064 | - name: Password Reset
1065 | description: Password recovery operations
1066 | - name: OAuth V1
1067 | description: OAuth version 1 endpoints
--------------------------------------------------------------------------------