diff --git a/.github/workflows/build-latest-backend.yaml b/.github/workflows/build-latest-backend.yaml index 61ef7b8..6efcde7 100644 --- a/.github/workflows/build-latest-backend.yaml +++ b/.github/workflows/build-latest-backend.yaml @@ -56,5 +56,5 @@ jobs: --header 'Authorization: Bearer ${{ secrets.ONEBOT_V11_TOKEN }}' \ --data '{ "group_id": ${{ secrets.ONEBOT_V11_GROUP_ID }}, - "message": "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 构建完成。" + "message": "Campux 构建完成。" }' \ No newline at end of file diff --git a/backend/config/config.go b/backend/config/config.go index 02b5e17..85e1a2b 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -2,6 +2,8 @@ package config import ( "github.com/spf13/viper" + + "github.com/google/uuid" ) type Config struct { @@ -12,9 +14,14 @@ func SetDefault() { viper.SetDefault("backend.port", "8080") // jwt - viper.SetDefault("auth.jwt.secret", "campux") + viper.SetDefault("auth.jwt.secret", uuid.New().String()) viper.SetDefault("auth.jwt.expire", 3600*6) + // oauth2 + viper.SetDefault("oauth2.server.code_secret", uuid.New().String()) + viper.SetDefault("oauth2.server.access_secret", uuid.New().String()) + viper.SetDefault("oauth2.server.ak_expire", 3600*24*14) + // 服务token viper.SetDefault("service.token", "campux") viper.SetDefault("service.bots", []int64{123456789}) @@ -38,6 +45,7 @@ func SetDefault() { viper.SetDefault("mq.redis.stream.new_post", "campux_new_post") viper.SetDefault("mq.redis.stream.post_cancel", "campux_post_cancel") viper.SetDefault("mq.redis.hash.post_publish_status", "campux_post_publish_status") + viper.SetDefault("mq.redis.prefix.oauth2_code", "campux_oauth2_code") } diff --git a/backend/controller/admapi.go b/backend/controller/admapi.go new file mode 100644 index 0000000..e9e028f --- /dev/null +++ b/backend/controller/admapi.go @@ -0,0 +1,119 @@ +package controller + +import ( + "github.com/RockChinQ/Campux/backend/database" + "github.com/RockChinQ/Campux/backend/service" + "github.com/gin-gonic/gin" +) + +type AdminRouter struct { + APIRouter + AdminService service.AdminService +} + +func NewAdminRouter(rg *gin.RouterGroup, as service.AdminService) *AdminRouter { + ar := &AdminRouter{ + AdminService: as, + } + + group := rg.Group("/admin") + + // bind routes + group.POST("/add-oauth2-app", ar.AddOAuth2App) + group.GET("/get-oauth2-apps", ar.GetOAuth2AppList) + group.DELETE("/del-oauth2-app/:id", ar.DeleteOAuth2App) + + return ar +} + +// 添加一个OAuth2应用 +func (ar *AdminRouter) AddOAuth2App(c *gin.Context) { + + uin, err := ar.Auth(c, UserOnly) + + if err != nil { + return + } + + if !ar.AdminService.CheckUserGroup(uin, []database.UserGroup{ + database.USER_GROUP_ADMIN, + }) { + ar.StatusCode(c, 401, "权限不足") + return + } + + // 取body的json里的appname + var body OAuth2AppCreateBody + + if err := c.ShouldBindJSON(&body); err != nil { + ar.Fail(c, 1, err.Error()) + return + } + + // 创建OAuth2应用 + app, err := ar.AdminService.AddOAuth2App(body.Name, body.Emoji) + + if err != nil { + ar.Fail(c, 2, err.Error()) + return + } + + ar.Success(c, app) +} + +// 获取 OAuth2 应用列表 +func (ar *AdminRouter) GetOAuth2AppList(c *gin.Context) { + uin, err := ar.Auth(c, UserOnly) + + if err != nil { + return + } + + if !ar.AdminService.CheckUserGroup(uin, []database.UserGroup{ + database.USER_GROUP_ADMIN, + }) { + ar.StatusCode(c, 401, "权限不足") + return + } + + // 获取OAuth2应用列表 + list, err := ar.AdminService.GetOAuth2Apps() + + if err != nil { + ar.Fail(c, 1, err.Error()) + return + } + + ar.Success(c, gin.H{ + "list": list, + }) +} + +// 删除一个OAuth2应用 +func (ar *AdminRouter) DeleteOAuth2App(c *gin.Context) { + uin, err := ar.Auth(c, UserOnly) + + if err != nil { + return + } + + if !ar.AdminService.CheckUserGroup(uin, []database.UserGroup{ + database.USER_GROUP_ADMIN, + }) { + ar.StatusCode(c, 401, "权限不足") + return + } + + // 取路由参数 + appID := c.Param("id") + + // 删除OAuth2应用 + err = ar.AdminService.DeleteOAuth2App(appID) + + if err != nil { + ar.Fail(c, 1, err.Error()) + return + } + + ar.Success(c, nil) +} diff --git a/backend/controller/api.go b/backend/controller/api.go index 6b48eda..12fc652 100644 --- a/backend/controller/api.go +++ b/backend/controller/api.go @@ -21,6 +21,8 @@ func NewApiController( as service.AccountService, ps service.PostService, ms service.MiscService, + ads service.AdminService, + oas service.OAuth2Service, ) *APIController { r := gin.Default() @@ -63,6 +65,8 @@ func NewApiController( NewAccountRouter(rg, as) NewPostRouter(rg, ps, as) NewMiscRouter(rg, ms) + NewAdminRouter(rg, ads) + NewOAuth2Router(rg, oas) return &APIController{ R: r, @@ -165,7 +169,7 @@ func (ar *APIRouter) GetUin(c *gin.Context) (int64, error) { // 删除Bearer jwtToken = jwtToken[7:] - uin, err := util.ParseJWTToken(jwtToken) + uin, err := util.ParseUserJWTToken(jwtToken) return uin, err } else { @@ -176,7 +180,7 @@ func (ar *APIRouter) GetUin(c *gin.Context) (int64, error) { return -1, err } - uin, err := util.ParseJWTToken(jwtToken) + uin, err := util.ParseUserJWTToken(jwtToken) return uin, err } diff --git a/backend/controller/dto.go b/backend/controller/dto.go index eb8d7f8..7ab4b4f 100644 --- a/backend/controller/dto.go +++ b/backend/controller/dto.go @@ -151,3 +151,27 @@ type GetBanListBody struct { // 时间排序 TimeOrder *int `json:"time_order" binding:"required"` } + +type OAuth2AppCreateBody struct { + // 名称 + Name string `json:"name" binding:"required"` + + // emoji + Emoji string `json:"emoji" binding:"required"` +} + +type OAuth2AuthorizeBody struct { + // 应用id + ClientID string `json:"client_id" binding:"required"` +} + +type OAuth2GetAccessTokenBody struct { + // 应用id + ClientID string `json:"client_id" binding:"required"` + + // 应用密钥 + ClientSecret string `json:"client_secret" binding:"required"` + + // 授权码 + Code string `json:"code" binding:"required"` +} diff --git a/backend/controller/oauthapi.go b/backend/controller/oauthapi.go new file mode 100644 index 0000000..5f10673 --- /dev/null +++ b/backend/controller/oauthapi.go @@ -0,0 +1,110 @@ +package controller + +import ( + "github.com/RockChinQ/Campux/backend/service" + "github.com/gin-gonic/gin" +) + +type OAuth2Router struct { + APIRouter + OAuth2Service service.OAuth2Service +} + +func NewOAuth2Router(rg *gin.RouterGroup, oas service.OAuth2Service) *OAuth2Router { + + oar := &OAuth2Router{ + OAuth2Service: oas, + } + + group := rg.Group("/oauth2") + + group.GET("/get-app-info", oar.GetOAuth2AppInfo) + group.GET("/authorize", oar.Authorize) + group.POST("/get-access-token", oar.GetAccessToken) + + return oar +} + +func (oar *OAuth2Router) GetOAuth2AppInfo(c *gin.Context) { + clientID := c.Query("client_id") + + app, err := oar.OAuth2Service.GetOAuth2AppByClientID(clientID) + + if err != nil { + oar.Fail(c, 1, err.Error()) + return + } + + if app == nil { + oar.Fail(c, 2, "此应用未注册") + return + } + + oar.Success(c, gin.H{ + "client_id": app.ClientID, + "name": app.Name, + }) +} + +func (oar *OAuth2Router) Authorize(c *gin.Context) { + + uin, err := oar.Auth(c, UserOnly) + + if err != nil { + return + } + + clientID := c.Query("client_id") + + if clientID == "" { + oar.Fail(c, 1, "未提供 client_id") + return + } + + // 检查是否存在这个应用 + app, err := oar.OAuth2Service.GetOAuth2AppByClientID(clientID) + + if err != nil { + oar.Fail(c, 2, err.Error()) + return + } + + if app == nil { + oar.Fail(c, 3, "此应用未注册") + return + } + + // 计算code + code, err := oar.OAuth2Service.GenerateCode(app.ClientID, uin) + + if err != nil { + oar.Fail(c, 4, err.Error()) + return + } + + oar.Success(c, gin.H{ + "code": code, + }) +} + +func (oar *OAuth2Router) GetAccessToken(c *gin.Context) { + + var body OAuth2GetAccessTokenBody + + if err := c.ShouldBindJSON(&body); err != nil { + oar.Fail(c, 1, err.Error()) + return + } + + // 检查code + ak, err := oar.OAuth2Service.GetAccessToken(body.ClientID, body.ClientSecret, body.Code) + + if err != nil { + oar.Fail(c, 2, err.Error()) + return + } + + oar.Success(c, gin.H{ + "access_token": ak, + }) +} diff --git a/backend/core/app.go b/backend/core/app.go index a79120e..7c92571 100644 --- a/backend/core/app.go +++ b/backend/core/app.go @@ -27,6 +27,8 @@ func NewApplication() *Application { as := service.NewAccountService(*db) ps := service.NewPostService(*db, *fs, *msq) ms := service.NewMiscService(*db) + ads := service.NewAdminService(*db) + oas := service.NewOAuth2Service(*db, *msq) err := ScheduleRoutines(*db, *msq) if err != nil { @@ -34,7 +36,7 @@ func NewApplication() *Application { } return &Application{ - API: controller.NewApiController(*as, *ps, *ms), + API: controller.NewApiController(*as, *ps, *ms, *ads, *oas), } } diff --git a/backend/database/mongo.go b/backend/database/mongo.go index c611674..6d13db1 100644 --- a/backend/database/mongo.go +++ b/backend/database/mongo.go @@ -18,6 +18,7 @@ const ( POST_VERBOSE_COLLECTION = "post_verbose" METADATA_COLLECTION = "metadata" BAN_LIST_COLLECTION = "ban_list" + OAUTH_APP_COLLECTION = "oauth_app" ) type Metadata struct { @@ -563,3 +564,62 @@ func (m *MongoDBManager) GetMetadata(key string) (string, error) { } return meta.Value, nil } + +func (m *MongoDBManager) AddOAuth2App(app *OAuthAppPO) error { + _, err := m.Client.Database(viper.GetString("database.mongo.db")).Collection(OAUTH_APP_COLLECTION).InsertOne(context.TODO(), app) + return err +} + +func (m *MongoDBManager) GetOAuth2App(clientID string) (*OAuthAppPO, error) { + var app OAuthAppPO + err := m.Client.Database(viper.GetString("database.mongo.db")).Collection(OAUTH_APP_COLLECTION).FindOne( + context.TODO(), + bson.M{"client_id": clientID}, + ).Decode(&app) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &app, nil +} + +func (m *MongoDBManager) GetOAuth2AppByName(name string) (*OAuthAppPO, error) { + // 若不存在,返回 nil, nil + var app OAuthAppPO + + err := m.Client.Database(viper.GetString("database.mongo.db")).Collection(OAUTH_APP_COLLECTION).FindOne( + context.TODO(), + bson.M{"name": name}, + ).Decode(&app) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil + } + return nil, err + } + return &app, nil +} + +// list +func (m *MongoDBManager) GetOAuth2Apps() ([]OAuthAppPO, error) { + var apps []OAuthAppPO + cursor, err := m.Client.Database(viper.GetString("database.mongo.db")).Collection(OAUTH_APP_COLLECTION).Find(context.TODO(), bson.M{}) + if err != nil { + return nil, err + } + defer cursor.Close(context.Background()) + + err = cursor.All(context.Background(), &apps) + if err != nil { + return nil, err + } + + return apps, nil +} + +func (m *MongoDBManager) DeleteOAuth2App(clientID string) error { + _, err := m.Client.Database(viper.GetString("database.mongo.db")).Collection(OAUTH_APP_COLLECTION).DeleteOne(context.TODO(), bson.M{"client_id": clientID}) + return err +} diff --git a/backend/database/po.go b/backend/database/po.go index 5b5d6b5..5502982 100644 --- a/backend/database/po.go +++ b/backend/database/po.go @@ -84,3 +84,11 @@ const ( REVIEW_OPTION_APPROVE ReviewOption = "approve" REVIEW_OPTION_REJECT ReviewOption = "reject" ) + +type OAuthAppPO struct { + Name string `json:"name" bson:"name"` // 应用名称 + Emoji string `json:"emoji" bson:"emoji"` // Emoji + ClientID string `json:"client_id" bson:"client_id"` // 客户端ID + ClientSecret string `json:"client_secret" bson:"client_secret"` // 客户端密钥 + CreatedAt time.Time `json:"created_at" bson:"created_at"` // CST时间 +} diff --git a/backend/mq/redis.go b/backend/mq/redis.go index a5f2929..2b5150a 100644 --- a/backend/mq/redis.go +++ b/backend/mq/redis.go @@ -3,6 +3,7 @@ package mq import ( "context" "strconv" + "time" "github.com/redis/go-redis/v9" "github.com/spf13/viper" @@ -109,3 +110,13 @@ func (r *RedisStreamMQ) DeletePostPublishStatus(postID int) error { _, err := r.Client.Del(context.Background(), viper.GetString("mq.redis.hash.post_publish_status")+strconv.Itoa(postID)).Result() return err } + +// 存储oauth2_code和uin对应关系 十分钟过期 +func (r *RedisStreamMQ) SetOauth2Code(code string, uin int64) error { + return r.Client.Set(context.Background(), viper.GetString("mq.redis.prefix.oauth2_code")+code, uin, 60*10*time.Second).Err() +} + +// 获取oauth2_code对应的uin +func (r *RedisStreamMQ) GetOauth2Uin(code string) (int64, error) { + return r.Client.Get(context.Background(), viper.GetString("mq.redis.prefix.oauth2_code")+code).Int64() +} diff --git a/backend/service/account.go b/backend/service/account.go index 7268c64..45ee01d 100644 --- a/backend/service/account.go +++ b/backend/service/account.go @@ -67,7 +67,7 @@ func (as *AccountService) CheckAccount(uin int64, pwd string) (string, error) { return "", ErrPasswordIncorrect } - jwt, err := util.GenerateJWTToken(uin) + jwt, err := util.GenerateUserJWTToken(uin) return jwt, err } diff --git a/backend/service/admin.go b/backend/service/admin.go new file mode 100644 index 0000000..88ac97f --- /dev/null +++ b/backend/service/admin.go @@ -0,0 +1,52 @@ +package service + +import ( + "github.com/RockChinQ/Campux/backend/database" + "github.com/RockChinQ/Campux/backend/util" + "github.com/google/uuid" +) + +type AdminService struct { + CommonService +} + +func NewAdminService(db database.MongoDBManager) *AdminService { + return &AdminService{ + CommonService: CommonService{ + DB: db, + }, + } +} + +func (as *AdminService) AddOAuth2App(name, emoji string) (*database.OAuthAppPO, error) { + check, err := as.DB.GetOAuth2AppByName(name) + + if err != nil { + return nil, err + } + + if check != nil { + return nil, ErrOAuth2AppAlreadyExist + } + + app := &database.OAuthAppPO{ + Name: name, + Emoji: emoji, + ClientID: util.RandomString(16), + ClientSecret: uuid.New().String(), + CreatedAt: util.GetCSTTime(), + } + + err = as.DB.AddOAuth2App(app) + + return app, err +} + +func (as *AdminService) GetOAuth2Apps() ([]database.OAuthAppPO, error) { + return as.DB.GetOAuth2Apps() +} + +// delete +func (as *AdminService) DeleteOAuth2App(appID string) error { + return as.DB.DeleteOAuth2App(appID) +} diff --git a/backend/service/errors.go b/backend/service/errors.go index 4bf8c6c..5232843 100644 --- a/backend/service/errors.go +++ b/backend/service/errors.go @@ -13,3 +13,9 @@ var ErrPasswordIncorrect = errors.New("密码错误") // 不允许的图片后缀 var ErrInvalidImageSuffix = errors.New("不允许的图片后缀") + +// OAuth2应用名称已存在 +var ErrOAuth2AppAlreadyExist = errors.New("OAuth2应用名称已存在") + +// OAuth2认证 Secret 不匹配 +var ErrOAuth2SecretNotMatch = errors.New("OAuth2 认证 Secret 不匹配") diff --git a/backend/service/oauth.go b/backend/service/oauth.go new file mode 100644 index 0000000..e494aa5 --- /dev/null +++ b/backend/service/oauth.go @@ -0,0 +1,67 @@ +package service + +import ( + "github.com/RockChinQ/Campux/backend/database" + "github.com/RockChinQ/Campux/backend/mq" + "github.com/RockChinQ/Campux/backend/util" + + "github.com/google/uuid" +) + +type OAuth2Service struct { + CommonService + MQ mq.RedisStreamMQ +} + +func NewOAuth2Service(db database.MongoDBManager, mq mq.RedisStreamMQ) *OAuth2Service { + return &OAuth2Service{ + CommonService: CommonService{ + DB: db, + }, + MQ: mq, + } +} + +func (oas *OAuth2Service) GetOAuth2AppByClientID(clientID string) (*database.OAuthAppPO, error) { + return oas.DB.GetOAuth2App(clientID) +} + +func (oas *OAuth2Service) GenerateCode(clientID string, uin int64) (string, error) { + codeUUID := uuid.New().String() + + err := oas.MQ.SetOauth2Code(codeUUID, uin) + + if err != nil { + return "", err + } + + return util.GenerateOAuth2CodeJWTToken(codeUUID, clientID) +} + +func (oas *OAuth2Service) GetAccessToken(clientID, clientSecret, code string) (string, error) { + codeUUID, err := util.ParseOAuth2CodeJWTToken(code, clientID) + + if err != nil { + return "", err + } + // 检查secret + app, err := oas.DB.GetOAuth2App(clientID) + + if err != nil { + return "", err + } + + if app.ClientSecret != clientSecret { + return "", ErrOAuth2SecretNotMatch + } + + uin, err := oas.MQ.GetOauth2Uin(codeUUID) + + if err != nil { + return "", err + } + + accessToken, err := util.GenerateOAuth2AccessTokenJWTToken(uin, clientID) + + return accessToken, err +} diff --git a/backend/util/jwt.go b/backend/util/jwt.go index 407be9b..bd98241 100644 --- a/backend/util/jwt.go +++ b/backend/util/jwt.go @@ -8,8 +8,8 @@ import ( ) // 生成jwt token -func GenerateJWTToken(uin int64) (string, error) { - +func GenerateUserJWTToken(uin int64) (string, error) { + // 生成token token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "uin": uin, @@ -26,8 +26,8 @@ func GenerateJWTToken(uin int64) (string, error) { } // 解析jwt token -func ParseJWTToken(tokenString string) (int64, error) { - +func ParseUserJWTToken(tokenString string) (int64, error) { + // 解析token token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { return []byte(viper.GetString("auth.jwt.secret")), nil @@ -48,4 +48,81 @@ func ParseJWTToken(tokenString string) (int64, error) { } return int64(uin), nil -} \ No newline at end of file +} + +func GenerateOAuth2CodeJWTToken(codeUUID, clientID string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "codeuuid": codeUUID, + "exp": time.Now().Add(time.Second * 60 * 10).Unix(), + }) + + tokenString, err := token.SignedString([]byte(viper.GetString("oauth2.server.code_secret") + clientID)) + if err != nil { + return "", err + } + + return tokenString, nil +} + +func ParseOAuth2CodeJWTToken(tokenString, clientID string) (string, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(viper.GetString("oauth2.server.code_secret") + clientID), nil + }) + + if err != nil { + return "", err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", err + } + + codeUUID, ok := claims["codeuuid"].(string) + if !ok { + return "", err + } + + return codeUUID, nil +} + +func GenerateOAuth2AccessTokenJWTToken(uin int64, clientID string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "uin": uin, + "cid": clientID, + "exp": time.Now().Add(time.Second * time.Duration(viper.GetInt("oauth2.server.ak_expire"))).Unix(), + }) + + tokenString, err := token.SignedString([]byte(viper.GetString("oauth2.server.access_secret"))) + if err != nil { + return "", err + } + + return tokenString, nil +} + +func ParseOAuth2AccessTokenJWTToken(tokenString string) (int64, string, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(viper.GetString("oauth2.server.access_secret")), nil + }) + if err != nil { + return 0, "", err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return 0, "", err + } + + uin, ok := claims["uin"].(float64) + if !ok { + return 0, "", err + } + + cid, ok := claims["cid"].(string) + if !ok { + return 0, "", err + } + + return int64(uin), cid, nil +} diff --git a/backend/util/string.go b/backend/util/string.go index b0320b9..eef5e9d 100644 --- a/backend/util/string.go +++ b/backend/util/string.go @@ -1,5 +1,7 @@ package util +import "math/rand" + func StringInSlice(str string, list []string) bool { for _, v := range list { if v == str { @@ -8,3 +10,15 @@ func StringInSlice(str string, list []string) bool { } return false } + +func RandomString(length int) string { + list := []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + + var result []byte + + for i := 0; i < length; i++ { + result = append(result, list[rand.Intn(len(list))]) + } + + return string(result) +} diff --git a/frontend/package.json b/frontend/package.json index 1cc48d5..985f96a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,9 +12,11 @@ "axios": "^1.6.8", "core-js": "^3.34.0", "js-cookie": "^3.0.5", + "mitt": "^3.0.1", "roboto-fontface": "*", "vue": "^3.4.0", "vue-cookies": "^1.8.4", + "vue3-emoji-picker": "^1.1.8", "vuetify": "^3.5.0", "vuex": "^4.1.0" }, diff --git a/frontend/src/components/BanRecordCard.vue b/frontend/src/components/BanRecordCard.vue index 1287ff9..60fac4c 100644 --- a/frontend/src/components/BanRecordCard.vue +++ b/frontend/src/components/BanRecordCard.vue @@ -30,7 +30,7 @@ + + \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js index 3859cea..03aa566 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -23,6 +23,10 @@ const app = createApp(App) app.use(store) app.use(VueCookies) +import mitt from 'mitt' + +const bus = mitt() + // let config=require("../config.json"); // fetch('/config.json').then(response => response.json()).then(config => { // console.log(config) @@ -41,6 +45,8 @@ const axiosInstance = axios.create({ }) app.config.globalProperties.$axios = { ...axiosInstance } +app.config.globalProperties.$bus = bus + registerPlugins(app) app.mount('#app') diff --git a/frontend/src/pages/admin.vue b/frontend/src/pages/admin.vue index 97acc0c..6d75453 100644 --- a/frontend/src/pages/admin.vue +++ b/frontend/src/pages/admin.vue @@ -7,6 +7,7 @@ 🪪 账号 🚫 封禁记录 + 🔑 OAuth 2 应用 @@ -23,7 +24,7 @@ - 查找 + 查找 - 查找 + 查找 @@ -61,6 +62,22 @@ + +
+ +
+ 新建 OAuth2 应用 + 刷新 +
+ +
+ + +
+
+
@@ -77,13 +94,40 @@ + + + 新建 OAuth2 应用 + + +
+

{{ newOAuthApp.emoji }}

+ +
+
+ + 取消 + 确定 + +
+
+