Skip to content

Commit 08ed069

Browse files
authored
Merge pull request #447 from 0xJacky/feat/2fa
[Feature] Two Factor Authorization
2 parents 8d8ba15 + bcff00c commit 08ed069

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+2769
-446
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 u.EnabledOTP() {
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: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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/internal/user"
12+
"github.com/0xJacky/Nginx-UI/query"
13+
"github.com/0xJacky/Nginx-UI/settings"
14+
"github.com/gin-gonic/gin"
15+
"github.com/pquerna/otp"
16+
"github.com/pquerna/otp/totp"
17+
"image/jpeg"
18+
"net/http"
19+
"strings"
20+
)
21+
22+
func GenerateTOTP(c *gin.Context) {
23+
u := api.CurrentUser(c)
24+
25+
issuer := fmt.Sprintf("Nginx UI %s", settings.ServerSettings.Name)
26+
issuer = strings.TrimSpace(issuer)
27+
28+
otpOpts := totp.GenerateOpts{
29+
Issuer: issuer,
30+
AccountName: u.Name,
31+
Period: 30, // seconds
32+
Digits: otp.DigitsSix,
33+
Algorithm: otp.AlgorithmSHA1,
34+
}
35+
otpKey, err := totp.Generate(otpOpts)
36+
if err != nil {
37+
api.ErrHandler(c, err)
38+
return
39+
}
40+
ciphertext, err := crypto.AesEncrypt([]byte(otpKey.Secret()))
41+
if err != nil {
42+
api.ErrHandler(c, err)
43+
return
44+
}
45+
46+
qrCode, err := otpKey.Image(512, 512)
47+
if err != nil {
48+
api.ErrHandler(c, err)
49+
return
50+
}
51+
52+
// Encode the image to a buffer
53+
var buf []byte
54+
buffer := bytes.NewBuffer(buf)
55+
err = jpeg.Encode(buffer, qrCode, nil)
56+
if err != nil {
57+
fmt.Println("Error encoding image:", err)
58+
return
59+
}
60+
61+
// Convert the buffer to a base64 string
62+
base64Str := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buffer.Bytes())
63+
64+
c.JSON(http.StatusOK, gin.H{
65+
"secret": base64.StdEncoding.EncodeToString(ciphertext),
66+
"qr_code": base64Str,
67+
})
68+
}
69+
70+
func EnrollTOTP(c *gin.Context) {
71+
cUser := api.CurrentUser(c)
72+
if cUser.EnabledOTP() {
73+
c.JSON(http.StatusBadRequest, gin.H{
74+
"message": "User already enrolled",
75+
})
76+
return
77+
}
78+
79+
if settings.ServerSettings.Demo {
80+
c.JSON(http.StatusBadRequest, gin.H{
81+
"message": "This feature is disabled in demo mode",
82+
})
83+
return
84+
}
85+
86+
var json struct {
87+
Secret string `json:"secret" binding:"required"`
88+
Passcode string `json:"passcode" binding:"required"`
89+
}
90+
if !api.BindAndValid(c, &json) {
91+
return
92+
}
93+
94+
secret, err := base64.StdEncoding.DecodeString(json.Secret)
95+
if err != nil {
96+
api.ErrHandler(c, err)
97+
return
98+
}
99+
100+
decrypted, err := crypto.AesDecrypt(secret)
101+
if err != nil {
102+
api.ErrHandler(c, err)
103+
return
104+
}
105+
106+
if ok := totp.Validate(json.Passcode, string(decrypted)); !ok {
107+
c.JSON(http.StatusNotAcceptable, gin.H{
108+
"message": "Invalid passcode",
109+
})
110+
return
111+
}
112+
113+
ciphertext, err := crypto.AesEncrypt(decrypted)
114+
if err != nil {
115+
api.ErrHandler(c, err)
116+
return
117+
}
118+
119+
u := query.Auth
120+
_, err = u.Where(u.ID.Eq(cUser.ID)).Update(u.OTPSecret, ciphertext)
121+
if err != nil {
122+
api.ErrHandler(c, err)
123+
return
124+
}
125+
126+
recoveryCode := sha1.Sum(ciphertext)
127+
128+
c.JSON(http.StatusOK, gin.H{
129+
"message": "ok",
130+
"recovery_code": hex.EncodeToString(recoveryCode[:]),
131+
})
132+
}
133+
134+
func ResetOTP(c *gin.Context) {
135+
var json struct {
136+
RecoveryCode string `json:"recovery_code"`
137+
}
138+
if !api.BindAndValid(c, &json) {
139+
return
140+
}
141+
recoverCode, err := hex.DecodeString(json.RecoveryCode)
142+
if err != nil {
143+
api.ErrHandler(c, err)
144+
return
145+
}
146+
cUser := api.CurrentUser(c)
147+
k := sha1.Sum(cUser.OTPSecret)
148+
if !bytes.Equal(k[:], recoverCode) {
149+
c.JSON(http.StatusBadRequest, gin.H{
150+
"message": "Invalid recovery code",
151+
})
152+
return
153+
}
154+
155+
u := query.Auth
156+
_, err = u.Where(u.ID.Eq(cUser.ID)).UpdateSimple(u.OTPSecret.Null())
157+
if err != nil {
158+
api.ErrHandler(c, err)
159+
return
160+
}
161+
162+
c.JSON(http.StatusOK, gin.H{
163+
"message": "ok",
164+
})
165+
}
166+
167+
func OTPStatus(c *gin.Context) {
168+
c.JSON(http.StatusOK, gin.H{
169+
"status": len(api.CurrentUser(c).OTPSecret) > 0,
170+
})
171+
}
172+
173+
func StartSecure2FASession(c *gin.Context) {
174+
var json struct {
175+
OTP string `json:"otp"`
176+
RecoveryCode string `json:"recovery_code"`
177+
}
178+
if !api.BindAndValid(c, &json) {
179+
return
180+
}
181+
u := api.CurrentUser(c)
182+
if !u.EnabledOTP() {
183+
c.JSON(http.StatusBadRequest, gin.H{
184+
"message": "User not configured with 2FA",
185+
})
186+
return
187+
}
188+
189+
if json.OTP == "" && json.RecoveryCode == "" {
190+
c.JSON(http.StatusBadRequest, LoginResponse{
191+
Message: "The user has enabled 2FA",
192+
})
193+
return
194+
}
195+
196+
if err := user.VerifyOTP(u, json.OTP, json.RecoveryCode); err != nil {
197+
c.JSON(http.StatusBadRequest, LoginResponse{
198+
Message: "Invalid 2FA or recovery code",
199+
})
200+
return
201+
}
202+
203+
sessionId := user.SetSecureSessionID(u.ID)
204+
205+
c.JSON(http.StatusOK, gin.H{
206+
"session_id": sessionId,
207+
})
208+
}

api/user/router.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,26 @@ 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)
26+
r.POST("/otp_secure_session", StartSecure2FASession)
1927
}

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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ 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+
OTP: typeof import('./src/components/OTP.vue')['default']
82+
OTPInput: typeof import('./src/components/OTPInput.vue')['default']
83+
OTPInputOTPInput: typeof import('./src/components/OTPInput/OTPInput.vue')['default']
84+
OTPOTPAuthorization: typeof import('./src/components/OTP/OTPAuthorization.vue')['default']
85+
OTPOTPAuthorizationModal: typeof import('./src/components/OTP/OTPAuthorizationModal.vue')['default']
8186
PageHeaderPageHeader: typeof import('./src/components/PageHeader/PageHeader.vue')['default']
8287
RouterLink: typeof import('vue-router')['RouterLink']
8388
RouterView: typeof import('vue-router')['RouterView']

0 commit comments

Comments
 (0)