Skip to content

Commit e93e83f

Browse files
committed
feat: support oidc login
1 parent 56b1874 commit e93e83f

File tree

16 files changed

+373
-42
lines changed

16 files changed

+373
-42
lines changed

api/settings/settings.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ func GetSettings(c *gin.Context) {
5252
"database": settings.DatabaseSettings,
5353
"auth": settings.AuthSettings,
5454
"casdoor": settings.CasdoorSettings,
55+
"oidc": settings.OIDCSettings,
5556
"cert": settings.CertSettings,
5657
"http": settings.HTTPSettings,
5758
"logrotate": settings.LogrotateSettings,
@@ -74,6 +75,7 @@ func SaveSettings(c *gin.Context) {
7475
Openai settings.OpenAI `json:"openai"`
7576
Logrotate settings.Logrotate `json:"logrotate"`
7677
Nginx settings.Nginx `json:"nginx"`
78+
Oidc settings.OIDC `json:"oidc"`
7779
}
7880

7981
if !cosy.BindAndValid(c, &json) {
@@ -130,6 +132,7 @@ func SaveSettings(c *gin.Context) {
130132
cSettings.ProtectedFill(settings.OpenAISettings, &json.Openai)
131133
cSettings.ProtectedFill(settings.LogrotateSettings, &json.Logrotate)
132134
cSettings.ProtectedFill(settings.NginxSettings, &json.Nginx)
135+
cSettings.ProtectedFill(settings.OIDCSettings, &json.Oidc)
133136

134137
err := settings.Save()
135138
if err != nil {

api/user/oidc.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package user
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"encoding/hex"
7+
"errors"
8+
"net/http"
9+
"strings"
10+
11+
"github.com/0xJacky/Nginx-UI/internal/user"
12+
"github.com/0xJacky/Nginx-UI/settings"
13+
"github.com/coreos/go-oidc/v3/oidc"
14+
"github.com/gin-gonic/gin"
15+
"github.com/uozi-tech/cosy"
16+
cSettings "github.com/uozi-tech/cosy/settings"
17+
"golang.org/x/oauth2"
18+
"gorm.io/gorm"
19+
)
20+
21+
type OIDCLoginUser struct {
22+
Code string `json:"code" binding:"required,max=255"`
23+
State string `json:"state" binding:"required,max=255"`
24+
}
25+
26+
func OIDCCallback(c *gin.Context) {
27+
var loginUser OIDCLoginUser
28+
29+
ok := cosy.BindAndValid(c, &loginUser)
30+
if !ok {
31+
return
32+
}
33+
34+
state, err := c.Cookie("oidc_state")
35+
if err != nil {
36+
c.JSON(http.StatusBadRequest, gin.H{
37+
"message": "State cookie not found",
38+
})
39+
return
40+
}
41+
42+
if state != loginUser.State {
43+
c.JSON(http.StatusForbidden, gin.H{
44+
"message": "State mismatch",
45+
})
46+
return
47+
}
48+
49+
c.SetCookie("oidc_state", "", -1, "/", "", cSettings.ServerSettings.EnableHTTPS, true)
50+
51+
endpoint := settings.OIDCSettings.Endpoint
52+
clientId := settings.OIDCSettings.ClientId
53+
clientSecret := settings.OIDCSettings.ClientSecret
54+
redirectUri := settings.OIDCSettings.RedirectUri
55+
56+
if endpoint == "" || clientId == "" || clientSecret == "" || redirectUri == "" {
57+
c.JSON(http.StatusInternalServerError, gin.H{
58+
"message": "OIDC is not configured",
59+
})
60+
return
61+
}
62+
63+
ctx := context.Background()
64+
provider, err := oidc.NewProvider(ctx, endpoint)
65+
if err != nil {
66+
cosy.ErrHandler(c, err)
67+
return
68+
}
69+
70+
scopes := []string{oidc.ScopeOpenID, "profile", "email"}
71+
if settings.OIDCSettings.Scopes != "" {
72+
scopes = strings.Split(settings.OIDCSettings.Scopes, " ")
73+
}
74+
75+
oauth2Config := oauth2.Config{
76+
ClientID: clientId,
77+
ClientSecret: clientSecret,
78+
RedirectURL: redirectUri,
79+
Endpoint: provider.Endpoint(),
80+
Scopes: scopes,
81+
}
82+
83+
oauth2Token, err := oauth2Config.Exchange(ctx, loginUser.Code)
84+
if err != nil {
85+
cosy.ErrHandler(c, err)
86+
return
87+
}
88+
89+
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
90+
if !ok {
91+
c.JSON(http.StatusInternalServerError, gin.H{
92+
"message": "No id_token field in oauth2 token",
93+
})
94+
return
95+
}
96+
97+
idTokenVerifier := provider.Verifier(&oidc.Config{ClientID: clientId})
98+
idToken, err := idTokenVerifier.Verify(ctx, rawIDToken)
99+
if err != nil {
100+
cosy.ErrHandler(c, err)
101+
return
102+
}
103+
104+
var claims map[string]interface{}
105+
106+
if err := idToken.Claims(&claims); err != nil {
107+
cosy.ErrHandler(c, err)
108+
return
109+
}
110+
111+
var username string
112+
113+
if settings.OIDCSettings.Identifier != "" {
114+
if v, ok := claims[settings.OIDCSettings.Identifier]; ok {
115+
username, _ = v.(string)
116+
}
117+
}
118+
119+
if username == "" {
120+
if v, ok := claims["email"]; ok {
121+
username, _ = v.(string)
122+
}
123+
}
124+
125+
if username == "" {
126+
if v, ok := claims["name"]; ok {
127+
username, _ = v.(string)
128+
}
129+
}
130+
131+
if username == "" {
132+
if v, ok := claims["sub"]; ok {
133+
username, _ = v.(string)
134+
}
135+
}
136+
137+
u, err := user.GetUser(username)
138+
if err != nil {
139+
if errors.Is(err, gorm.ErrRecordNotFound) {
140+
c.JSON(http.StatusForbidden, gin.H{
141+
"message": "User not exist",
142+
})
143+
} else {
144+
cosy.ErrHandler(c, err)
145+
}
146+
return
147+
}
148+
149+
userToken, err := user.GenerateJWT(u)
150+
if err != nil {
151+
cosy.ErrHandler(c, err)
152+
return
153+
}
154+
155+
c.JSON(http.StatusOK, LoginResponse{
156+
Message: "ok",
157+
AccessTokenPayload: userToken,
158+
})
159+
}
160+
161+
func GetOIDCUri(c *gin.Context) {
162+
endpoint := settings.OIDCSettings.Endpoint
163+
clientId := settings.OIDCSettings.ClientId
164+
redirectUri := settings.OIDCSettings.RedirectUri
165+
scopes := settings.OIDCSettings.Scopes
166+
167+
if endpoint == "" || clientId == "" || redirectUri == "" {
168+
c.JSON(http.StatusOK, gin.H{
169+
"uri": "",
170+
})
171+
return
172+
}
173+
174+
ctx := context.Background()
175+
provider, err := oidc.NewProvider(ctx, endpoint)
176+
if err != nil {
177+
cosy.ErrHandler(c, err)
178+
return
179+
}
180+
181+
scopeList := []string{oidc.ScopeOpenID, "profile", "email"}
182+
if scopes != "" {
183+
scopeList = strings.Split(scopes, " ")
184+
}
185+
186+
oauth2Config := oauth2.Config{
187+
ClientID: clientId,
188+
RedirectURL: redirectUri,
189+
Endpoint: provider.Endpoint(),
190+
Scopes: scopeList,
191+
}
192+
193+
b := make([]byte, 16)
194+
_, err = rand.Read(b)
195+
if err != nil {
196+
cosy.ErrHandler(c, err)
197+
return
198+
}
199+
200+
state := "nginx-ui-oidc_" + hex.EncodeToString(b)
201+
202+
c.SetCookie("oidc_state", state, 300, "/", "", cSettings.ServerSettings.EnableHTTPS, true)
203+
204+
c.JSON(http.StatusOK, gin.H{
205+
"uri": oauth2Config.AuthCodeURL(state),
206+
})
207+
}

api/user/router.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ func InitAuthRouter(r *gin.RouterGroup) {
1515
r.GET("/casdoor_uri", GetCasdoorUri)
1616
r.POST("/casdoor_callback", CasdoorCallback)
1717

18+
r.GET("/oidc_uri", GetOIDCUri)
19+
r.POST("/oidc_callback", OIDCCallback)
20+
1821
r.GET("/passkeys/config", GetPasskeyConfigStatus)
1922
}
2023

app.example.ini

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ Organization =
2727
Application =
2828
RedirectUri =
2929

30+
[oidc]
31+
ClientId =
32+
ClientSecret =
33+
Endpoint =
34+
RedirectUri =
35+
Scopes =
36+
Identifier =
37+
3038
[cert]
3139
Email =
3240
CADir =

app/src/api/auth.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ const auth = {
3131
login(r.token, r.short_token)
3232
})
3333
},
34+
async oidc_login(code?: string, state?: string) {
35+
await http.post('/oidc_callback', {
36+
code,
37+
state,
38+
})
39+
.then((r: AuthResponse) => {
40+
login(r.token, r.short_token)
41+
})
42+
},
3443
async logout() {
3544
return http.delete('/logout').then(async () => {
3645
logout()
@@ -39,6 +48,9 @@ const auth = {
3948
async get_casdoor_uri(): Promise<{ uri: string }> {
4049
return http.get('/casdoor_uri')
4150
},
51+
async get_oidc_uri(): Promise<{ uri: string }> {
52+
return http.get('/oidc_uri')
53+
},
4254
begin_passkey_login() {
4355
return http.get('/begin_passkey_login')
4456
},

app/src/api/settings.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,22 @@ export interface BannedIP {
110110
expired_at: string
111111
}
112112

113+
export interface OIDCSettings {
114+
client_id: string
115+
client_secret: string
116+
endpoint: string
117+
redirect_uri: string
118+
scopes: string
119+
identifier: string
120+
}
121+
113122
export interface Settings {
114123
app: AppSettings
115124
server: ServerSettings
116125
database: DatabaseSettings
117126
auth: AuthSettings
118127
casdoor: CasdoorSettings
128+
oidc: OIDCSettings
119129
cert: CertSettings
120130
http: HTTPSettings
121131
logrotate: LogrotateSettings

app/src/views/other/Login.vue

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,11 @@ async function handleLoginSuccess(options: LoginSuccessOptions = {}) {
134134
await settingsStore.set_language(userStore.info?.language)
135135
}
136136
137+
if (window.location.search) {
138+
const newUrl = window.location.pathname + window.location.hash
139+
window.history.replaceState(null, '', newUrl)
140+
}
141+
137142
const next = (route.query?.next || '').toString() || '/'
138143
await router.push(next)
139144
}
@@ -187,17 +192,46 @@ auth.get_casdoor_uri()
187192
}
188193
})
189194
195+
const has_oidc = ref(false)
196+
const oidc_uri = ref('')
197+
198+
auth.get_oidc_uri()
199+
.then(r => {
200+
if (r?.uri) {
201+
has_oidc.value = true
202+
oidc_uri.value = r.uri
203+
}
204+
})
205+
190206
function loginWithCasdoor() {
191207
window.location.href = casdoor_uri.value
192208
}
193209
194-
if (route.query?.code !== undefined && route.query?.state !== undefined) {
210+
function loginWithOIDC() {
211+
window.location.href = oidc_uri.value
212+
}
213+
214+
const searchParams = new URLSearchParams(window.location.search)
215+
const query = route.query
216+
const code = query?.code?.toString() ?? searchParams.get('code')
217+
const state = query?.state?.toString() ?? searchParams.get('state')
218+
219+
if (code && state) {
195220
loading.value = true
196-
auth.casdoor_login(route.query?.code?.toString(), route.query?.state?.toString()).then(async () => {
197-
await handleLoginSuccess()
198-
}).finally(() => {
199-
loading.value = false
200-
})
221+
if (state.startsWith('nginx-ui-oidc_')) {
222+
auth.oidc_login(code, state).then(async () => {
223+
await handleLoginSuccess()
224+
}).finally(() => {
225+
loading.value = false
226+
})
227+
}
228+
else {
229+
auth.casdoor_login(code, state).then(async () => {
230+
await handleLoginSuccess()
231+
}).finally(() => {
232+
loading.value = false
233+
})
234+
}
201235
}
202236
203237
function handleOTPSubmit(code: string, recovery: string) {
@@ -291,6 +325,15 @@ async function handlePasskeyLogin() {
291325
>
292326
{{ $gettext('SSO Login') }}
293327
</AButton>
328+
<AButton
329+
v-if="has_oidc"
330+
block
331+
:loading="loading"
332+
class="mb-5"
333+
@click="loginWithOIDC"
334+
>
335+
{{ $gettext('OIDC Login') }}
336+
</AButton>
294337
</template>
295338
<div v-else>
296339
<Authorization

0 commit comments

Comments
 (0)