Skip to content

Commit 5abd9b7

Browse files
committed
feat: login 2fa
1 parent 8d8ba15 commit 5abd9b7

File tree

33 files changed

+1062
-121
lines changed

33 files changed

+1062
-121
lines changed

.idea/vcs.xml

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/api.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import (
44
"errors"
55
"github.com/0xJacky/Nginx-UI/internal/logger"
6+
"github.com/0xJacky/Nginx-UI/model"
67
"github.com/gin-gonic/gin"
78
"github.com/go-playground/validator/v10"
89
"net/http"
@@ -11,6 +12,10 @@ import (
1112
"strings"
1213
)
1314

15+
func CurrentUser(c *gin.Context) *model.Auth {
16+
return c.MustGet("user").(*model.Auth)
17+
}
18+
1419
func ErrHandler(c *gin.Context, err error) {
1520
logger.GetLogger().Errorln(err)
1621
c.JSON(http.StatusInternalServerError, gin.H{

api/user/auth.go

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,19 @@ import (
1616
var mutex = &sync.Mutex{}
1717

1818
type LoginUser struct {
19-
Name string `json:"name" binding:"required,max=255"`
20-
Password string `json:"password" binding:"required,max=255"`
19+
Name string `json:"name" binding:"required,max=255"`
20+
Password string `json:"password" binding:"required,max=255"`
21+
OTP string `json:"otp"`
22+
RecoveryCode string `json:"recovery_code"`
2123
}
2224

2325
const (
2426
ErrPasswordIncorrect = 4031
2527
ErrMaxAttempts = 4291
2628
ErrUserBanned = 4033
29+
Enabled2FA = 199
30+
Error2FACode = 4034
31+
LoginSuccess = 200
2732
)
2833

2934
type LoginResponse struct {
@@ -80,11 +85,32 @@ func Login(c *gin.Context) {
8085
return
8186
}
8287

88+
// Check if the user enables 2FA
89+
if len(u.OTPSecret) > 0 {
90+
if json.OTP == "" && json.RecoveryCode == "" {
91+
c.JSON(http.StatusOK, LoginResponse{
92+
Message: "The user has enabled 2FA",
93+
Code: Enabled2FA,
94+
})
95+
user.BanIP(clientIP)
96+
return
97+
}
98+
99+
if err = user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
100+
c.JSON(http.StatusForbidden, LoginResponse{
101+
Message: "Invalid 2FA or recovery code",
102+
Code: Error2FACode,
103+
})
104+
user.BanIP(clientIP)
105+
return
106+
}
107+
}
108+
83109
// login success, clear banned record
84110
_, _ = b.Where(b.IP.Eq(clientIP)).Delete()
85111

86112
logger.Info("[User Login]", u.Name)
87-
token, err := user.GenerateJWT(u.Name)
113+
token, err := user.GenerateJWT(u)
88114
if err != nil {
89115
c.JSON(http.StatusInternalServerError, LoginResponse{
90116
Message: err.Error(),
@@ -93,6 +119,7 @@ func Login(c *gin.Context) {
93119
}
94120

95121
c.JSON(http.StatusOK, LoginResponse{
122+
Code: LoginSuccess,
96123
Message: "ok",
97124
Token: token,
98125
})
@@ -101,13 +128,7 @@ func Login(c *gin.Context) {
101128
func Logout(c *gin.Context) {
102129
token := c.GetHeader("Authorization")
103130
if token != "" {
104-
err := user.DeleteToken(token)
105-
if err != nil {
106-
c.JSON(http.StatusInternalServerError, gin.H{
107-
"message": err.Error(),
108-
})
109-
return
110-
}
131+
user.DeleteToken(token)
111132
}
112133
c.JSON(http.StatusNoContent, nil)
113134
}

api/user/casdoor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func CasdoorCallback(c *gin.Context) {
6565
return
6666
}
6767

68-
userToken, err := user.GenerateJWT(u.Name)
68+
userToken, err := user.GenerateJWT(u)
6969
if err != nil {
7070
api.ErrHandler(c, err)
7171
return

api/user/otp.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package user
2+
3+
import (
4+
"bytes"
5+
"crypto/sha1"
6+
"encoding/base64"
7+
"encoding/hex"
8+
"fmt"
9+
"github.com/0xJacky/Nginx-UI/api"
10+
"github.com/0xJacky/Nginx-UI/internal/crypto"
11+
"github.com/0xJacky/Nginx-UI/query"
12+
"github.com/0xJacky/Nginx-UI/settings"
13+
"github.com/gin-gonic/gin"
14+
"github.com/pquerna/otp"
15+
"github.com/pquerna/otp/totp"
16+
"image/jpeg"
17+
"net/http"
18+
"strings"
19+
)
20+
21+
func GenerateTOTP(c *gin.Context) {
22+
user := api.CurrentUser(c)
23+
24+
issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name)
25+
issuer = strings.TrimSpace(issuer)
26+
27+
otpOpts := totp.GenerateOpts{
28+
Issuer: issuer,
29+
AccountName: user.Name,
30+
Period: 30, // seconds
31+
Digits: otp.DigitsSix,
32+
Algorithm: otp.AlgorithmSHA1,
33+
}
34+
otpKey, err := totp.Generate(otpOpts)
35+
if err != nil {
36+
api.ErrHandler(c, err)
37+
return
38+
}
39+
ciphertext, err := crypto.AesEncrypt([]byte(otpKey.Secret()))
40+
if err != nil {
41+
api.ErrHandler(c, err)
42+
return
43+
}
44+
45+
qrCode, err := otpKey.Image(512, 512)
46+
if err != nil {
47+
api.ErrHandler(c, err)
48+
return
49+
}
50+
51+
// Encode the image to a buffer
52+
var buf []byte
53+
buffer := bytes.NewBuffer(buf)
54+
err = jpeg.Encode(buffer, qrCode, nil)
55+
if err != nil {
56+
fmt.Println("Error encoding image:", err)
57+
return
58+
}
59+
60+
// Convert the buffer to a base64 string
61+
base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes())
62+
63+
c.JSON(http.StatusOK, gin.H{
64+
"secret": base64.StdEncoding.EncodeToString(ciphertext),
65+
"qr_code": base64Str,
66+
})
67+
}
68+
69+
func EnrollTOTP(c *gin.Context) {
70+
user := api.CurrentUser(c)
71+
if len(user.OTPSecret) > 0 {
72+
c.JSON(http.StatusBadRequest, gin.H{
73+
"message": "User already enrolled",
74+
})
75+
return
76+
}
77+
78+
var json struct {
79+
Secret string `json:"secret" binding:"required"`
80+
Passcode string `json:"passcode" binding:"required"`
81+
}
82+
if !api.BindAndValid(c, &json) {
83+
return
84+
}
85+
86+
secret, err := base64.StdEncoding.DecodeString(json.Secret)
87+
if err != nil {
88+
api.ErrHandler(c, err)
89+
return
90+
}
91+
92+
decrypted, err := crypto.AesDecrypt(secret)
93+
if err != nil {
94+
api.ErrHandler(c, err)
95+
return
96+
}
97+
98+
if ok := totp.Validate(json.Passcode, string(decrypted)); !ok {
99+
c.JSON(http.StatusNotAcceptable, gin.H{
100+
"message": "Invalid passcode",
101+
})
102+
return
103+
}
104+
105+
ciphertext, err := crypto.AesEncrypt(decrypted)
106+
if err != nil {
107+
api.ErrHandler(c, err)
108+
return
109+
}
110+
111+
u := query.Auth
112+
_, err = u.Where(u.ID.Eq(user.ID)).Update(u.OTPSecret, ciphertext)
113+
if err != nil {
114+
api.ErrHandler(c, err)
115+
return
116+
}
117+
118+
recoveryCode := sha1.Sum(ciphertext)
119+
120+
c.JSON(http.StatusOK, gin.H{
121+
"message": "ok",
122+
"recovery_code": hex.EncodeToString(recoveryCode[:]),
123+
})
124+
}
125+
126+
func ResetOTP(c *gin.Context) {
127+
var json struct {
128+
RecoveryCode string `json:"recovery_code"`
129+
}
130+
if !api.BindAndValid(c, &json) {
131+
return
132+
}
133+
recoverCode, err := hex.DecodeString(json.RecoveryCode)
134+
if err != nil {
135+
api.ErrHandler(c, err)
136+
return
137+
}
138+
user := api.CurrentUser(c)
139+
k := sha1.Sum(user.OTPSecret)
140+
if !bytes.Equal(k[:], recoverCode) {
141+
c.JSON(http.StatusBadRequest, gin.H{
142+
"message": "Invalid recovery code",
143+
})
144+
return
145+
}
146+
147+
u := query.Auth
148+
_, err = u.Where(u.ID.Eq(user.ID)).UpdateSimple(u.OTPSecret.Null())
149+
if err != nil {
150+
api.ErrHandler(c, err)
151+
return
152+
}
153+
154+
c.JSON(http.StatusOK, gin.H{
155+
"message": "ok",
156+
})
157+
}
158+
159+
func OTPStatus(c *gin.Context) {
160+
c.JSON(http.StatusOK, gin.H{
161+
"status": len(api.CurrentUser(c).OTPSecret) > 0,
162+
})
163+
}

api/user/router.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,25 @@ package user
22

33
import "github.com/gin-gonic/gin"
44

5-
func InitAuthRouter(r *gin.RouterGroup) {
6-
r.POST("/login", Login)
7-
r.DELETE("/logout", Logout)
5+
func InitAuthRouter(r *gin.RouterGroup) {
6+
r.POST("/login", Login)
7+
r.DELETE("/logout", Logout)
88

9-
r.GET("/casdoor_uri", GetCasdoorUri)
10-
r.POST("/casdoor_callback", CasdoorCallback)
9+
r.GET("/casdoor_uri", GetCasdoorUri)
10+
r.POST("/casdoor_callback", CasdoorCallback)
1111
}
1212

1313
func InitManageUserRouter(r *gin.RouterGroup) {
14-
r.GET("users", GetUsers)
15-
r.GET("user/:id", GetUser)
16-
r.POST("user", AddUser)
17-
r.POST("user/:id", EditUser)
18-
r.DELETE("user/:id", DeleteUser)
14+
r.GET("users", GetUsers)
15+
r.GET("user/:id", GetUser)
16+
r.POST("user", AddUser)
17+
r.POST("user/:id", EditUser)
18+
r.DELETE("user/:id", DeleteUser)
19+
}
20+
21+
func InitUserRouter(r *gin.RouterGroup) {
22+
r.GET("/otp_status", OTPStatus)
23+
r.GET("/otp_secret", GenerateTOTP)
24+
r.POST("/otp_enroll", EnrollTOTP)
25+
r.POST("/otp_reset", ResetOTP)
1926
}

app.example.ini

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,11 @@ Interval = 1440
5151
Node = http://10.0.0.1:9000?name=node1&node_secret=my-node-secret&enabled=true
5252
Node = http://10.0.0.2:9000?name=node2&node_secret=my-node-secret&enabled=true
5353
Node = http://10.0.0.3?name=node3&node_secret=my-node-secret&enabled=true
54+
55+
[auth]
56+
IPWhiteList =
57+
BanThresholdMinutes = 10
58+
MaxAttempts = 10
59+
60+
[crypto]
61+
Secret = secret2

app/components.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ declare module 'vue' {
7878
NginxControlNginxControl: typeof import('./src/components/NginxControl/NginxControl.vue')['default']
7979
NodeSelectorNodeSelector: typeof import('./src/components/NodeSelector/NodeSelector.vue')['default']
8080
NotificationNotification: typeof import('./src/components/Notification/Notification.vue')['default']
81+
OTPInput: typeof import('./src/components/OTPInput.vue')['default']
82+
OTPInputOTPInput: typeof import('./src/components/OTPInput/OTPInput.vue')['default']
8183
PageHeaderPageHeader: typeof import('./src/components/PageHeader/PageHeader.vue')['default']
8284
RouterLink: typeof import('vue-router')['RouterLink']
8385
RouterView: typeof import('vue-router')['RouterView']

app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"vue3-ace-editor": "2.2.4",
3939
"vue3-apexcharts": "1.4.4",
4040
"vue3-gettext": "3.0.0-beta.4",
41+
"vue3-otp-input": "^0.5.21",
4142
"vuedraggable": "^4.1.0"
4243
},
4344
"devDependencies": {

app/pnpm-lock.yaml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)