├── .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 | 6 | -------------------------------------------------------------------------------- /.idea/copilot.data.migration.ask.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/copilot.data.migration.edit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/copilot.data.migration.ask2agent.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | 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 --------------------------------------------------------------------------------