Skip to content

Commit 9de74b6

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

File tree

12 files changed

+310
-40
lines changed

12 files changed

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

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: 57 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,54 @@ 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+
const uri = oidc_uri.value
212+
const url = new URL(uri)
213+
const state = url.searchParams.get('state')
214+
if (state) {
215+
sessionStorage.setItem('oidc_state', state)
216+
}
217+
window.location.href = uri
218+
}
219+
220+
const searchParams = new URLSearchParams(window.location.search)
221+
const query = route.query
222+
const code = query?.code?.toString() ?? searchParams.get('code')
223+
const state = query?.state?.toString() ?? searchParams.get('state')
224+
225+
if (code && state) {
195226
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-
})
227+
const savedState = sessionStorage.getItem('oidc_state')
228+
if (savedState && state === savedState) {
229+
sessionStorage.removeItem('oidc_state')
230+
auth.oidc_login(code, state).then(async () => {
231+
await handleLoginSuccess()
232+
}).finally(() => {
233+
loading.value = false
234+
})
235+
}
236+
else {
237+
auth.casdoor_login(code, state).then(async () => {
238+
await handleLoginSuccess()
239+
}).finally(() => {
240+
loading.value = false
241+
})
242+
}
201243
}
202244
203245
function handleOTPSubmit(code: string, recovery: string) {
@@ -291,6 +333,15 @@ async function handlePasskeyLogin() {
291333
>
292334
{{ $gettext('SSO Login') }}
293335
</AButton>
336+
<AButton
337+
v-if="has_oidc"
338+
block
339+
:loading="loading"
340+
class="mb-5"
341+
@click="loginWithOIDC"
342+
>
343+
{{ $gettext('OIDC Login') }}
344+
</AButton>
294345
</template>
295346
<div v-else>
296347
<Authorization

app/src/views/preference/store/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ const useSystemSettingsStore = defineStore('systemSettings', () => {
3838
application: '',
3939
redirect_uri: '',
4040
},
41+
oidc: {
42+
client_id: '',
43+
client_secret: '',
44+
endpoint: '',
45+
redirect_uri: '',
46+
scopes: '',
47+
identifier: '',
48+
},
4149
cert: {
4250
email: '',
4351
ca_dir: '',

0 commit comments

Comments
 (0)