WIP: Full restruct, seprate service and api

Signed-off-by: Asai Neko <sugar@sne.moe>
This commit is contained in:
2026-01-24 11:42:35 +08:00
parent dfd5532b20
commit 8e11ba4631
31 changed files with 830 additions and 248 deletions

168
internal/ali_cnrid/kyc.go Normal file
View File

@@ -0,0 +1,168 @@
package kyc
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"nixcn-cms/internal/cryptography"
"unicode/utf8"
alicloudauth20190307 "github.com/alibabacloud-go/cloudauth-20190307/v4/client"
aliopenapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
aliutil "github.com/alibabacloud-go/tea-utils/v2/service"
alitea "github.com/alibabacloud-go/tea/tea"
alicredential "github.com/aliyun/credentials-go/credentials"
"github.com/spf13/viper"
)
func DecodeB64Json(b64Json string) (*KycInfo, error) {
rawJson, err := base64.StdEncoding.DecodeString(b64Json)
if err != nil {
return nil, errors.New("[KYC] invalid base64 json")
}
var kyc KycInfo
if err := json.Unmarshal(rawJson, &kyc); err != nil {
return nil, errors.New("[KYC] invalid json structure")
}
return &kyc, nil
}
func EncodeAES(kyc *KycInfo) (*string, error) {
plainJson, err := json.Marshal(kyc)
if err != nil {
return nil, err
}
aesKey := viper.GetString("secrets.kyc_info_key")
encrypted, err := cryptography.AESCBCEncrypt(plainJson, []byte(aesKey))
if err != nil {
return nil, err
}
return &encrypted, nil
}
func DecodeAES(cipherStr string) (*KycInfo, error) {
aesKey := viper.GetString("secrets.kyc_info_key")
plainBytes, err := cryptography.AESCBCDecrypt(cipherStr, []byte(aesKey))
if err != nil {
return nil, err
}
var kyc KycInfo
if err := json.Unmarshal(plainBytes, &kyc); err != nil {
return nil, errors.New("[KYC] invalid decrypted json")
}
return &kyc, nil
}
func MD5AliEnc(kyc *KycInfo) (*KycAli, error) {
if kyc.Type != "Chinese" {
return nil, nil
}
// MD5 Legal Name rule: First Chinese char md5enc, remaining plain, at least 2 Chinese chars
if len(kyc.LegalName) < 2 || utf8.RuneCountInString(kyc.LegalName) < 2 {
return nil, fmt.Errorf("input string must have at least 2 Chinese characters")
}
lnFirstRune, size := utf8.DecodeRuneInString(kyc.LegalName)
if lnFirstRune == utf8.RuneError {
return nil, fmt.Errorf("invalid first character")
}
lnHash := md5.New()
lnHash.Write([]byte(string(lnFirstRune)))
lnFirstHash := hex.EncodeToString(lnHash.Sum(nil))
lnRemaining := kyc.LegalName[size:]
ln := lnFirstHash + lnRemaining
// MD5 Resident Id rule: First 6 char plain, middle birthdate md5enc, last 4 char plain, at least 18 chars
if len(kyc.ResidentId) < 18 {
return nil, fmt.Errorf("input string must have at least 18 characters")
}
ridPrefix := kyc.ResidentId[:6]
ridSuffix := kyc.ResidentId[len(kyc.ResidentId)-4:]
ridMiddle := kyc.ResidentId[6 : len(kyc.ResidentId)-4]
ridHash := md5.New()
ridHash.Write([]byte(ridMiddle))
ridMiddleHash := hex.EncodeToString(ridHash.Sum(nil))
rid := ridPrefix + ridMiddleHash + ridSuffix
// Aliyun Id2MetaVerify API Params
var kycAli KycAli
kycAli.ParamType = "md5"
kycAli.UserName = ln
kycAli.IdentifyNum = rid
return &kycAli, nil
}
func AliId2MetaVerify(kycAli *KycAli) (*string, error) {
// Create aliyun openapi credential
credentialConfig := new(alicredential.Config).
SetType("access_key").
SetAccessKeyId(viper.GetString("kyc.ali_access_key_id")).
SetAccessKeySecret(viper.GetString("kyc.ali_access_key_secret"))
credential, err := alicredential.NewCredential(credentialConfig)
if err != nil {
return nil, err
}
// Create aliyun cloudauth client
config := &aliopenapi.Config{
Credential: credential,
}
config.Endpoint = alitea.String("cloudauth.aliyuncs.com")
client := &alicloudauth20190307.Client{}
client, err = alicloudauth20190307.NewClient(config)
if err != nil {
return nil, err
}
// Create Id2MetaVerify request
id2MetaVerifyRequest := &alicloudauth20190307.Id2MetaVerifyRequest{
ParamType: &kycAli.ParamType,
UserName: &kycAli.UserName,
IdentifyNum: &kycAli.IdentifyNum,
}
// Create client runtime request
runtime := &aliutil.RuntimeOptions{}
resp, tryErr := func() (*alicloudauth20190307.Id2MetaVerifyResponse, error) {
defer func() {
if r := alitea.Recover(recover()); r != nil {
err = r
}
}()
resp, err := client.Id2MetaVerifyWithOptions(id2MetaVerifyRequest, runtime)
if err != nil {
return nil, err
}
return resp, nil
}()
// Try error handler ??? from ali generated sdk
if tryErr != nil {
var error = &alitea.SDKError{}
if t, ok := tryErr.(*alitea.SDKError); ok {
error = t
} else {
error.Message = alitea.String(tryErr.Error())
}
return nil, error
}
return resp.Body.ResultObject.BizCode, err
}

View File

@@ -0,0 +1,7 @@
package kyc
type KycAli struct {
ParamType string `json:"param_type"`
IdentifyNum string `json:"identify_num"`
UserName string `json:"user_name"`
}

View File

@@ -0,0 +1,65 @@
package authcode
import (
"context"
"crypto/rand"
"encoding/base64"
"nixcn-cms/data"
"github.com/spf13/viper"
)
type Token struct {
ClientId string
Email string
}
func NewAuthCode(ctx context.Context, clientId string, email string) (string, error) {
// generate random code
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
code := base64.RawURLEncoding.EncodeToString(b)
key := "auth_code:" + code
ttl := viper.GetDuration("ttl.auth_code_ttl")
// store auth code metadata in Redis
if err := data.Redis.HSet(
ctx,
key,
map[string]any{
"client_id": clientId,
"email": email,
},
).Err(); err != nil {
return "", err
}
// set expiration (one-time auth code)
if err := data.Redis.Expire(ctx, key, ttl).Err(); err != nil {
return "", err
}
return code, nil
}
func VerifyAuthCode(ctx context.Context, code string) (*Token, bool) {
key := "auth_code:" + code
// Read auth code payload
dataMap, err := data.Redis.HGetAll(ctx, key).Result()
if err != nil || len(dataMap) == 0 {
return nil, false
}
// Delete auth code immediately (one-time use)
_ = data.Redis.Del(ctx, key).Err()
return &Token{
ClientId: dataMap["client_id"],
Email: dataMap["email"],
}, true
}

View File

@@ -0,0 +1,291 @@
package authtoken
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"nixcn-cms/data"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/spf13/viper"
)
type Token struct {
Application string
}
type JwtClaims struct {
ClientId string `json:"client_id"`
UserID uuid.UUID `json:"user_id"`
jwt.RegisteredClaims
}
// Generate jwt clames
func (self *Token) NewClaims(clientId string, userId uuid.UUID) JwtClaims {
return JwtClaims{
ClientId: clientId,
UserID: userId,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(viper.GetDuration("ttl.access_ttl"))),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: self.Application,
},
}
}
// Generate access token
func (self *Token) GenerateAccessToken(ctx context.Context, clientId string, userId uuid.UUID) (string, error) {
claims := self.NewClaims(clientId, userId)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
clientData, err := new(data.Client).GetClientByClientId(ctx, clientId)
if err != nil {
return "", fmt.Errorf("error getting client data: %v", err)
}
secret, err := clientData.GetDecryptedSecret()
if err != nil {
return "", fmt.Errorf("error getting decrypted secret: %v", err)
}
signedToken, err := token.SignedString([]byte(secret))
if err != nil {
return "", fmt.Errorf("error signing token: %v", err)
}
return signedToken, nil
}
// Generate refresh token
func (self *Token) GenerateRefreshToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
// Issue both access and refresh token
func (self *Token) IssueTokens(ctx context.Context, clientId string, userId uuid.UUID) (string, string, error) {
// access token
access, err := self.GenerateAccessToken(ctx, clientId, userId)
if err != nil {
return "", "", err
}
// refresh token
refresh, err := self.GenerateRefreshToken()
if err != nil {
return "", "", err
}
ttl := viper.GetDuration("ttl.refresh_ttl")
refreshKey := "refresh:" + refresh
// refresh -> user + client
if err := data.Redis.HSet(
ctx,
refreshKey,
map[string]any{
"user_id": userId.String(),
"client_id": clientId,
},
).Err(); err != nil {
return "", "", err
}
if err := data.Redis.Expire(ctx, refreshKey, ttl).Err(); err != nil {
return "", "", err
}
// user -> refresh tokens
userSetKey := "user:" + userId.String() + ":refresh_tokens"
if err := data.Redis.SAdd(ctx, userSetKey, refresh).Err(); err != nil {
return "", "", err
}
_ = data.Redis.Expire(ctx, userSetKey, ttl).Err()
// client -> refresh tokens
clientSetKey := "client:" + clientId + ":refresh_tokens"
_ = data.Redis.SAdd(ctx, clientSetKey, refresh).Err()
_ = data.Redis.Expire(ctx, clientSetKey, ttl).Err()
return access, refresh, nil
}
// Refresh access token
func (self *Token) RefreshAccessToken(ctx context.Context, refreshToken string) (string, error) {
key := "refresh:" + refreshToken
// read refresh token bind data
dataMap, err := data.Redis.HGetAll(ctx, key).Result()
if err != nil || len(dataMap) == 0 {
return "", errors.New("[Auth Token] invalid refresh token")
}
userIdStr := dataMap["user_id"]
clientId := dataMap["client_id"]
if userIdStr == "" || clientId == "" {
return "", errors.New("[Auth Token] refresh token corrupted")
}
userId, err := uuid.Parse(userIdStr)
if err != nil {
return "", err
}
// Generate new access token
return self.GenerateAccessToken(ctx, clientId, userId)
}
func (self *Token) RenewRefreshToken(ctx context.Context, refreshToken string) (string, error) {
ttl := viper.GetDuration("ttl.refresh_ttl")
oldKey := "refresh:" + refreshToken
// read old refresh token bind data
dataMap, err := data.Redis.HGetAll(ctx, oldKey).Result()
if err != nil || len(dataMap) == 0 {
return "", errors.New("[Auth Token] invalid refresh token")
}
userIdStr := dataMap["user_id"]
clientId := dataMap["client_id"]
if userIdStr == "" || clientId == "" {
return "", errors.New("[Auth Token] refresh token corrupted")
}
// generate new refresh token
newRefresh, err := self.GenerateRefreshToken()
if err != nil {
return "", err
}
// revoke old refresh token
if err := self.RevokeRefreshToken(ctx, refreshToken); err != nil {
return "", err
}
newKey := "refresh:" + newRefresh
// refresh -> user + client
if err := data.Redis.HSet(
ctx,
newKey,
map[string]any{
"user_id": userIdStr,
"client_id": clientId,
},
).Err(); err != nil {
return "", err
}
if err := data.Redis.Expire(ctx, newKey, ttl).Err(); err != nil {
return "", err
}
// user -> refresh tokens
userSetKey := "user:" + userIdStr + ":refresh_tokens"
if err := data.Redis.SAdd(ctx, userSetKey, newRefresh).Err(); err != nil {
return "", err
}
_ = data.Redis.Expire(ctx, userSetKey, ttl).Err()
// client -> refresh tokens
clientSetKey := "client:" + clientId + ":refresh_tokens"
_ = data.Redis.SAdd(ctx, clientSetKey, newRefresh).Err()
_ = data.Redis.Expire(ctx, clientSetKey, ttl).Err()
return newRefresh, nil
}
func (self *Token) RevokeRefreshToken(ctx context.Context, refreshToken string) error {
refreshKey := "refresh:" + refreshToken
// read refresh token metadata (user_id, client_id)
dataMap, err := data.Redis.HGetAll(ctx, refreshKey).Result()
if err != nil || len(dataMap) == 0 {
// Token already revoked or not found
return nil
}
userID := dataMap["user_id"]
clientID := dataMap["client_id"]
// build index keys
userSetKey := "user:" + userID + ":refresh_tokens"
clientSetKey := "client:" + clientID + ":refresh_tokens"
// remove refresh token and all related indexes atomically
pipe := data.Redis.TxPipeline()
// remove main refresh token record
pipe.Del(ctx, refreshKey)
// remove refresh token from user's active refresh token set
pipe.SRem(ctx, userSetKey, refreshToken)
// remove refresh token from client's active refresh token set
pipe.SRem(ctx, clientSetKey, refreshToken)
_, err = pipe.Exec(ctx)
return err
}
func (self *Token) HeaderVerify(ctx context.Context, header string) (string, error) {
if header == "" {
return "", nil
}
// Split header to 2
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return "", errors.New("[Auth Token] invalid Authorization header format")
}
tokenStr := parts[1]
// Verify access token
claims := &JwtClaims{}
token, err := jwt.ParseWithClaims(
tokenStr,
claims,
func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("[Auth Token] unexpected signing method")
}
if claims.ClientId == "" {
return nil, errors.New("[Auth Token] client_id missing in token")
}
clientData, err := new(data.Client).GetClientByClientId(ctx, claims.ClientId)
if err != nil {
return nil, fmt.Errorf("error getting client data: %v", err)
}
secret, err := clientData.GetDecryptedSecret()
if err != nil {
return nil, fmt.Errorf("error getting decrypted secret: %v", err)
}
return secret, nil
},
)
if err != nil || !token.Valid {
fmt.Println(err)
return "", errors.New("[Auth Token] invalid or expired token")
}
return claims.UserID.String(), nil
}

84
internal/email/email.go Normal file
View File

@@ -0,0 +1,84 @@
package email
import (
"crypto/tls"
"errors"
"strings"
"time"
"github.com/spf13/viper"
gomail "gopkg.in/gomail.v2"
)
type Client struct {
dialer *gomail.Dialer
host string
port int
username string
security string
insecure bool
}
func (self *Client) NewSMTPClient() (*Client, error) {
host := viper.GetString("email.host")
port := viper.GetInt("email.port")
user := viper.GetString("email.username")
pass := viper.GetString("email.password")
security := strings.ToLower(viper.GetString("email.security"))
insecure := viper.GetBool("email.insecure_skip_verify")
if host == "" || port == 0 || user == "" {
return nil, errors.New("[Email] SMTP config not set")
}
if pass == "" {
return nil, errors.New("[Email] SMTP basic auth requires email.password")
}
dialer := gomail.NewDialer(host, port, user, pass)
dialer.TLSConfig = &tls.Config{
ServerName: host,
InsecureSkipVerify: insecure,
}
switch security {
case "ssl":
dialer.SSL = true
case "starttls":
dialer.SSL = false
case "plain", "":
dialer.SSL = false
dialer.TLSConfig = nil
default:
return nil, errors.New("[Email] unknown smtp security mode: " + security)
}
return &Client{
dialer: dialer,
host: host,
port: port,
username: user,
security: security,
insecure: insecure,
}, nil
}
func (c *Client) Send(from, to, subject, html string) (string, error) {
if c.dialer == nil {
return "", errors.New("[Email] SMTP dialer not initialized")
}
m := gomail.NewMessage()
m.SetHeader("From", from)
m.SetHeader("To", to)
m.SetHeader("Subject", subject)
m.SetBody("text/html", html)
if err := c.dialer.DialAndSend(m); err != nil {
return "", err
}
return time.Now().Format(time.RFC3339Nano), nil
}

View File

@@ -13,12 +13,13 @@ import (
// :5=original
type Builder struct {
Status string
Service string
Endpoint string
Type string
Original string
Error error
Status string
Service string
Endpoint string
Type string
Original string
Error error
ErrorCode string
}
func (self *Builder) SetStatus(s string) *Builder {
@@ -51,16 +52,25 @@ func (self *Builder) SetError(e error) *Builder {
return self
}
func (self *Builder) Build(ctx context.Context) string {
errorCode := fmt.Sprintf("%s%s%s%s%s",
func (self *Builder) build() {
self.ErrorCode = fmt.Sprintf("%s%s%s%s%s",
self.Status,
self.Service,
self.Endpoint,
self.Type,
self.Original,
)
if self.Error != nil {
ErrorHandler(ctx, self.Status, errorCode, self.Error)
}
return errorCode
}
func (self *Builder) Throw(ctx *context.Context) *Builder {
self.build()
if self.Error != nil {
ErrorHandler(ctx, self.Status, self.ErrorCode, self.Error)
}
return self
}
func (self *Builder) String() string {
self.build()
return self.ErrorCode
}

View File

@@ -5,15 +5,15 @@ import (
"log/slog"
)
func ErrorHandler(ctx context.Context, status string, errorCode string, err error) {
func ErrorHandler(ctx *context.Context, status string, errorCode string, err error) {
switch status {
case StatusSuccess:
slog.InfoContext(ctx, "Service exception! ErrId: "+errorCode, "id", errorCode, "err", err)
slog.InfoContext(*ctx, "Service exception! ErrId: "+errorCode, "id", errorCode, "err", err)
case StatusUser:
slog.WarnContext(ctx, "Service exception! ErrId: "+errorCode, "id", errorCode, "err", err)
slog.WarnContext(*ctx, "Service exception! ErrId: "+errorCode, "id", errorCode, "err", err)
case StatusServer:
slog.ErrorContext(ctx, "Service exception! ErrId: "+errorCode, "id", errorCode, "err", err)
slog.ErrorContext(*ctx, "Service exception! ErrId: "+errorCode, "id", errorCode, "err", err)
case StatusClient:
slog.ErrorContext(ctx, "Service exception! ErrId: "+errorCode, "id", errorCode, "err", err)
slog.ErrorContext(*ctx, "Service exception! ErrId: "+errorCode, "id", errorCode, "err", err)
}
}

17
internal/kyc/types.go Normal file
View File

@@ -0,0 +1,17 @@
package kyc
type KycInfo struct {
Type string `json:"type"` // cnrid/passport
LegalName string `json:"legal_name"`
ResidentId string `json:"rsident_id"`
PassportInfo PassportInfo `json:"passport_info"`
}
type PassportInfo struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
DateOfExpire string `json:"date_of_expire"`
Nationality string `json:"nationality"`
DocumentType string `json:"document_type"`
DocumentNumber string `json:"document_number"`
}

View File

@@ -0,0 +1,13 @@
package screalid
type KycPassportResponse struct {
Status string `json:"status"`
FinalResult struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
DateOfExpire string `json:"dateOfExpire"`
Nationality string `json:"nationality"`
DocumentType string `json:"documentType"`
DocumentNumber string `json:"documentNumber"`
} `json:"finalResult"`
}

View File

@@ -0,0 +1,34 @@
package turnstile
import (
"encoding/json"
"net/http"
"net/url"
"github.com/spf13/viper"
)
func VerifyTurnstile(token, ip string) (bool, error) {
form := url.Values{}
form.Set("secret", viper.GetString("secrets.turnstile_secret"))
form.Set("response", token)
form.Set("remoteip", ip)
resp, err := http.PostForm(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
form,
)
if err != nil {
return false, err
}
defer resp.Body.Close()
var result struct {
Success bool `json:"success"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return false, err
}
return result.Success, nil
}