- middleware: JWT MapClaims 타입 단언 패닉 → ok 패턴으로 방어 - auth/service: Redis Set 오류 처리, 지갑 생성 실패 시 유저 롤백 - auth/service: EnsureAdmin 지갑 생성 추가, Logout 리프레시 토큰도 삭제 - auth/service: 리프레시 토큰 발급(7일) 및 로테이션, REFRESH_SECRET 분리 - auth/handler: Login 응답에 refreshToken 포함, Refresh 핸들러 추가 - auth/handler: Logout 에러 처리 추가 - download/service: hashGameExeFromZip io.Copy 오류 처리 - download/handler: Content-Disposition mime.FormatMediaType으로 헤더 인젝션 방어 - announcement/handler: Update 빈 body 400 반환 - config: REFRESH_SECRET 환경변수 추가 - routes: POST /api/auth/refresh 엔드포인트 추가 - main: INTERNAL_API_KEY 미설정 시 경고 출력 - .env.example: 누락 환경변수 7개 보완 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
19
.env.example
19
.env.example
@@ -10,7 +10,22 @@ REDIS_ADDR=localhost:6379
|
|||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
JWT_SECRET=your-secret-key-here
|
JWT_SECRET=your-secret-key-here
|
||||||
JWT_EXPIRY_HOURS=24
|
REFRESH_SECRET=your-refresh-secret-key-here
|
||||||
|
JWT_EXPIRY_HOURS=1
|
||||||
|
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=admin1234
|
ADMIN_PASSWORD=admin1234
|
||||||
|
|
||||||
|
BASE_URL=http://localhost:8080
|
||||||
|
GAME_DIR=/data/game
|
||||||
|
|
||||||
|
# Chain integration
|
||||||
|
CHAIN_NODE_URL=http://localhost:8545
|
||||||
|
CHAIN_ID=tolchain-dev
|
||||||
|
# 운영자 지갑 개인키 (hex, 비워두면 mint/reward 불가)
|
||||||
|
OPERATOR_KEY_HEX=
|
||||||
|
# AES-256 암호화 키 - 반드시 64자 hex (32 bytes) 설정 필요
|
||||||
|
WALLET_ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# 게임 서버 → API 서버 내부 통신용 API 키 (비워두면 /api/internal/* 비활성화)
|
||||||
|
INTERNAL_API_KEY=
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ func (h *Handler) Update(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&body); err != nil {
|
if err := c.BodyParser(&body); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"})
|
||||||
}
|
}
|
||||||
|
if body.Title == "" && body.Content == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "수정할 내용을 입력해주세요"})
|
||||||
|
}
|
||||||
a, err := h.svc.Update(c.Params("id"), body.Title, body.Content)
|
a, err := h.svc.Update(c.Params("id"), body.Title, body.Content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
|||||||
@@ -42,21 +42,43 @@ func (h *Handler) Login(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "아이디와 비밀번호를 입력해주세요"})
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenStr, user, err := h.svc.Login(req.Username, req.Password)
|
accessToken, refreshToken, user, err := h.svc.Login(req.Username, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"token": tokenStr,
|
"token": accessToken,
|
||||||
"username": user.Username,
|
"refreshToken": refreshToken,
|
||||||
"role": user.Role,
|
"username": user.Username,
|
||||||
|
"role": user.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Refresh(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil || req.RefreshToken == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "refreshToken 필드가 필요합니다"})
|
||||||
|
}
|
||||||
|
|
||||||
|
newAccessToken, newRefreshToken, err := h.svc.Refresh(req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"token": newAccessToken,
|
||||||
|
"refreshToken": newRefreshToken,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Logout(c *fiber.Ctx) error {
|
func (h *Handler) Logout(c *fiber.Ctx) error {
|
||||||
userID := c.Locals("userID").(uint)
|
userID := c.Locals("userID").(uint)
|
||||||
h.svc.Logout(userID)
|
if err := h.svc.Logout(userID); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "로그아웃 처리 중 오류가 발생했습니다"})
|
||||||
|
}
|
||||||
return c.JSON(fiber.Map{"message": "로그아웃 되었습니다"})
|
return c.JSON(fiber.Map{"message": "로그아웃 되었습니다"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const refreshTokenExpiry = 7 * 24 * time.Hour
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID uint `json:"user_id"`
|
UserID uint `json:"user_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -29,22 +31,34 @@ func NewService(repo *Repository, rdb *redis.Client) *Service {
|
|||||||
return &Service{repo: repo, rdb: rdb}
|
return &Service{repo: repo, rdb: rdb}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetWalletCreator sets the callback invoked after user registration
|
|
||||||
// to create a blockchain wallet.
|
|
||||||
func (s *Service) SetWalletCreator(fn func(userID uint) error) {
|
func (s *Service) SetWalletCreator(fn func(userID uint) error) {
|
||||||
s.walletCreator = fn
|
s.walletCreator = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Login(username, password string) (string, *User, error) {
|
func (s *Service) Login(username, password string) (accessToken, refreshToken string, user *User, err error) {
|
||||||
user, err := s.repo.FindByUsername(username)
|
user, err = s.repo.FindByUsername(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
|
return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
return "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
|
return "", "", nil, fmt.Errorf("아이디 또는 비밀번호가 올바르지 않습니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accessToken, err = s.issueAccessToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err = s.issueRefreshToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, refreshToken, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) issueAccessToken(user *User) (string, error) {
|
||||||
expiry := time.Duration(config.C.JWTExpiryHours) * time.Hour
|
expiry := time.Duration(config.C.JWTExpiryHours) * time.Hour
|
||||||
claims := &Claims{
|
claims := &Claims{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
@@ -58,19 +72,86 @@ func (s *Service) Login(username, password string) (string, *User, error) {
|
|||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
tokenStr, err := token.SignedString([]byte(config.C.JWTSecret))
|
tokenStr, err := token.SignedString([]byte(config.C.JWTSecret))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, fmt.Errorf("토큰 생성에 실패했습니다")
|
return "", fmt.Errorf("토큰 생성에 실패했습니다")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redis에 세션 저장 (1계정 1세션)
|
|
||||||
key := fmt.Sprintf("session:%d", user.ID)
|
key := fmt.Sprintf("session:%d", user.ID)
|
||||||
s.rdb.Set(context.Background(), key, tokenStr, expiry)
|
if err := s.rdb.Set(context.Background(), key, tokenStr, expiry).Err(); err != nil {
|
||||||
|
return "", fmt.Errorf("세션 저장에 실패했습니다")
|
||||||
|
}
|
||||||
|
|
||||||
return tokenStr, user, nil
|
return tokenStr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Logout(userID uint) {
|
func (s *Service) issueRefreshToken(user *User) (string, error) {
|
||||||
key := fmt.Sprintf("session:%d", userID)
|
claims := &Claims{
|
||||||
s.rdb.Del(context.Background(), key)
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: string(user.Role),
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenExpiry)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenStr, err := token.SignedString([]byte(config.C.RefreshSecret))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("리프레시 토큰 생성에 실패했습니다")
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fmt.Sprintf("refresh:%d", user.ID)
|
||||||
|
if err := s.rdb.Set(context.Background(), key, tokenStr, refreshTokenExpiry).Err(); err != nil {
|
||||||
|
return "", fmt.Errorf("리프레시 토큰 저장에 실패했습니다")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh validates a refresh token and issues a new access + refresh token pair (rotation).
|
||||||
|
func (s *Service) Refresh(refreshTokenStr string) (newAccessToken, newRefreshToken string, err error) {
|
||||||
|
token, err := jwt.ParseWithClaims(refreshTokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method")
|
||||||
|
}
|
||||||
|
return []byte(config.C.RefreshSecret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return "", "", fmt.Errorf("유효하지 않은 리프레시 토큰입니다")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("토큰 파싱 실패")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis에서 저장된 리프레시 토큰과 비교
|
||||||
|
key := fmt.Sprintf("refresh:%d", claims.UserID)
|
||||||
|
stored, err := s.rdb.Get(context.Background(), key).Result()
|
||||||
|
if err != nil || stored != refreshTokenStr {
|
||||||
|
return "", "", fmt.Errorf("만료되었거나 유효하지 않은 리프레시 토큰입니다")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &User{ID: claims.UserID, Username: claims.Username, Role: Role(claims.Role)}
|
||||||
|
|
||||||
|
newAccessToken, err = s.issueAccessToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리프레시 토큰 로테이션
|
||||||
|
newRefreshToken, err = s.issueRefreshToken(user)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newAccessToken, newRefreshToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Logout(userID uint) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
sessionKey := fmt.Sprintf("session:%d", userID)
|
||||||
|
refreshKey := fmt.Sprintf("refresh:%d", userID)
|
||||||
|
return s.rdb.Del(ctx, sessionKey, refreshKey).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetAllUsers() ([]User, error) {
|
func (s *Service) GetAllUsers() ([]User, error) {
|
||||||
@@ -103,7 +184,9 @@ func (s *Service) Register(username, password string) error {
|
|||||||
}
|
}
|
||||||
if s.walletCreator != nil {
|
if s.walletCreator != nil {
|
||||||
if err := s.walletCreator(user.ID); err != nil {
|
if err := s.walletCreator(user.ID); err != nil {
|
||||||
log.Printf("WARNING: wallet creation failed for user %d: %v", user.ID, err)
|
log.Printf("wallet creation failed for user %d: %v — rolling back", user.ID, err)
|
||||||
|
s.repo.Delete(fmt.Sprintf("%d", user.ID))
|
||||||
|
return fmt.Errorf("계정 초기화에 실패했습니다. 잠시 후 다시 시도해주세요")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -137,15 +220,24 @@ func (s *Service) VerifyToken(tokenStr string) (string, error) {
|
|||||||
|
|
||||||
func (s *Service) EnsureAdmin(username, password string) error {
|
func (s *Service) EnsureAdmin(username, password string) error {
|
||||||
if _, err := s.repo.FindByUsername(username); err == nil {
|
if _, err := s.repo.FindByUsername(username); err == nil {
|
||||||
return nil // 이미 존재하면 스킵
|
return nil
|
||||||
}
|
}
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.repo.Create(&User{
|
user := &User{
|
||||||
Username: username,
|
Username: username,
|
||||||
PasswordHash: string(hash),
|
PasswordHash: string(hash),
|
||||||
Role: RoleAdmin,
|
Role: RoleAdmin,
|
||||||
})
|
}
|
||||||
|
if err := s.repo.Create(user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.walletCreator != nil {
|
||||||
|
if err := s.walletCreator(user.ID); err != nil {
|
||||||
|
log.Printf("WARNING: admin wallet creation failed for user %d: %v", user.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package download
|
package download
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ func (h *Handler) ServeFile(c *fiber.Ctx) error {
|
|||||||
if info != nil && info.FileName != "" {
|
if info != nil && info.FileName != "" {
|
||||||
filename = info.FileName
|
filename = info.FileName
|
||||||
}
|
}
|
||||||
c.Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
c.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
|
||||||
return c.SendFile(path)
|
return c.SendFile(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,8 +143,11 @@ func hashGameExeFromZip(zipPath string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
io.Copy(h, rc)
|
_, err = io.Copy(h, rc)
|
||||||
rc.Close()
|
rc.Close()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return hex.EncodeToString(h.Sum(nil))
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
main.go
4
main.go
@@ -67,6 +67,10 @@ func main() {
|
|||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if config.C.InternalAPIKey == "" {
|
||||||
|
log.Println("WARN: INTERNAL_API_KEY not set — /api/internal/* endpoints are disabled")
|
||||||
|
}
|
||||||
|
|
||||||
annRepo := announcement.NewRepository(database.DB)
|
annRepo := announcement.NewRepository(database.DB)
|
||||||
annSvc := announcement.NewService(annRepo)
|
annSvc := announcement.NewService(annRepo)
|
||||||
annHandler := announcement.NewHandler(annSvc)
|
annHandler := announcement.NewHandler(annSvc)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Config struct {
|
|||||||
RedisAddr string
|
RedisAddr string
|
||||||
RedisPassword string
|
RedisPassword string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
RefreshSecret string
|
||||||
JWTExpiryHours int
|
JWTExpiryHours int
|
||||||
AdminUsername string
|
AdminUsername string
|
||||||
AdminPassword string
|
AdminPassword string
|
||||||
@@ -49,6 +50,7 @@ func Load() {
|
|||||||
RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"),
|
RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"),
|
||||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||||
JWTSecret: getEnv("JWT_SECRET", "secret"),
|
JWTSecret: getEnv("JWT_SECRET", "secret"),
|
||||||
|
RefreshSecret: getEnv("REFRESH_SECRET", "refresh-secret"),
|
||||||
JWTExpiryHours: hours,
|
JWTExpiryHours: hours,
|
||||||
AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
|
AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
|
||||||
AdminPassword: getEnv("ADMIN_PASSWORD", "admin1234"),
|
AdminPassword: getEnv("ADMIN_PASSWORD", "admin1234"),
|
||||||
|
|||||||
@@ -28,10 +28,23 @@ func Auth(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
||||||
}
|
}
|
||||||
|
|
||||||
claims := token.Claims.(jwt.MapClaims)
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
userID := uint(claims["user_id"].(float64))
|
if !ok {
|
||||||
username := claims["username"].(string)
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
||||||
role := claims["role"].(string)
|
}
|
||||||
|
userIDFloat, ok := claims["user_id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
||||||
|
}
|
||||||
|
username, ok := claims["username"].(string)
|
||||||
|
if !ok {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
||||||
|
}
|
||||||
|
role, ok := claims["role"].(string)
|
||||||
|
if !ok {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "유효하지 않은 토큰입니다"})
|
||||||
|
}
|
||||||
|
userID := uint(userIDFloat)
|
||||||
|
|
||||||
// Redis 세션 확인
|
// Redis 세션 확인
|
||||||
key := fmt.Sprintf("session:%d", userID)
|
key := fmt.Sprintf("session:%d", userID)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func Register(
|
|||||||
a := api.Group("/auth")
|
a := api.Group("/auth")
|
||||||
a.Post("/register", authH.Register)
|
a.Post("/register", authH.Register)
|
||||||
a.Post("/login", authH.Login)
|
a.Post("/login", authH.Login)
|
||||||
|
a.Post("/refresh", authH.Refresh)
|
||||||
a.Post("/logout", middleware.Auth, authH.Logout)
|
a.Post("/logout", middleware.Auth, authH.Logout)
|
||||||
a.Post("/verify", authH.VerifyToken)
|
a.Post("/verify", authH.VerifyToken)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user