Skip to content

Commit 34c4424

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

File tree

12 files changed

+293
-40
lines changed

12 files changed

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

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 === '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

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: '',

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/caarlos0/env/v11 v11.3.1
1515
github.com/casdoor/casdoor-go-sdk v1.40.0
1616
github.com/cloudflare/cloudflare-go/v6 v6.4.0
17+
github.com/coreos/go-oidc/v3 v3.17.0
1718
github.com/creack/pty v1.1.24
1819
github.com/dgraph-io/ristretto/v2 v2.3.0
1920
github.com/docker/docker v28.5.2+incompatible
@@ -58,6 +59,7 @@ require (
5859
github.com/urfave/cli/v3 v3.6.1
5960
golang.org/x/crypto v0.46.0
6061
golang.org/x/net v0.48.0
62+
golang.org/x/oauth2 v0.33.0
6163
google.golang.org/grpc v1.77.0
6264
gopkg.in/ini.v1 v1.67.0
6365
gorm.io/datatypes v1.2.7
@@ -353,7 +355,6 @@ require (
353355
go.yaml.in/yaml/v3 v3.0.4 // indirect
354356
golang.org/x/arch v0.23.0 // indirect
355357
golang.org/x/mod v0.30.0 // indirect
356-
golang.org/x/oauth2 v0.33.0 // indirect
357358
golang.org/x/sync v0.19.0 // indirect
358359
golang.org/x/sys v0.39.0 // indirect
359360
golang.org/x/text v0.32.0 // indirect

0 commit comments

Comments
 (0)