Skip to content

Commit 07288a8

Browse files
committed
feat: add oidc #131
1 parent b14ebe0 commit 07288a8

16 files changed

+415
-33
lines changed

build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ dependencies {
4343
implementation("org.springframework.boot:spring-boot-starter-web")
4444
implementation("org.springframework.boot:spring-boot-starter-webflux")
4545
implementation("org.springframework.boot:spring-boot-starter-security")
46+
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
4647
implementation("org.springframework.data:spring-data-jdbc") // required since exposed 0.51.0
4748
implementation("org.springframework.session:spring-session-core")
4849
implementation("org.springframework.session:spring-session-jdbc")
@@ -73,6 +74,7 @@ dependencies {
7374
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
7475
implementation("com.github.slugify:slugify:3.0.7")
7576
implementation("commons-io:commons-io:2.16.1")
77+
implementation("org.apache.commons:commons-lang3:3.17.0")
7678
implementation("commons-validator:commons-validator:1.7")
7779
implementation("org.jsoup:jsoup:1.18.1")
7880
implementation("net.coobird:thumbnailator:0.4.20")

src/jelu-ui/src/components/Login.vue

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { key } from '../store'
55
import { StringUtils } from '../utils/StringUtils'
66
import { useI18n } from 'vue-i18n'
77
import { useTitle } from '@vueuse/core'
8+
import dataService from "../services/DataService";
9+
import { OAuth2ClientDto } from '../model/oauth-client-dto'
10+
import urls from '../urls'
811
912
const { t } = useI18n({
1013
inheritLocale: true,
@@ -17,6 +20,13 @@ const loginValidation = ref('')
1720
const passwordValidation = ref('')
1821
const errorMessage = ref('')
1922
const progress: Ref<boolean> = ref(false)
23+
const providers: Ref<Array<OAuth2ClientDto>> = ref([])
24+
25+
const getOauthproviders = () => {
26+
dataService.oauth2Providers().then(res => {
27+
providers.value = res
28+
})
29+
}
2030
2131
useTitle('Jelu | Login')
2232
@@ -106,18 +116,42 @@ const createInitialUser = async () => {
106116
}
107117
}
108118
}
109-
onMounted(() => {
110-
console.log(`form data ${form}`)
111-
})
112119
113-
const submit = () => {
114-
if (displayInitialSetup.value) {
115-
createInitialUser()
116-
}
117-
else {
118-
logUser()
119-
}
120+
const oauth2Login = (provider: OAuth2ClientDto) => {
121+
const url = `${urls.BASE_URL}/oauth2/authorization/${provider.registrationId}`
122+
const height = 600
123+
const width = 600
124+
const y = window.top!.outerHeight / 2 + window.top!.screenY - (height / 2)
125+
const x = window.top!.outerWidth / 2 + window.top!.screenX - (width / 2)
126+
window.open(url, 'oauth2Login',
127+
`toolbar=no,
128+
location=off,
129+
status=no,
130+
menubar=no,
131+
scrollbars=yes,
132+
resizable=yes,
133+
top=${y},
134+
left=${x},
135+
width=${height},
136+
height=${width}`,
137+
)
120138
}
139+
140+
// onMounted(() => {
141+
// console.log(`form data ${form}`)
142+
// })
143+
144+
const submit = () => {
145+
if (displayInitialSetup.value) {
146+
createInitialUser()
147+
}
148+
else {
149+
logUser()
150+
}
151+
}
152+
153+
154+
getOauthproviders()
121155
</script>
122156

123157
<template>
@@ -218,6 +252,20 @@ const submit = () => {
218252
{{ errorMessage }}
219253
</p>
220254
</div>
255+
<div class="mt-3">
256+
<button
257+
v-for="provider in providers"
258+
:key="provider.name"
259+
class="btn btn-info mx-2 capitalize"
260+
:disabled="displayInitialSetup"
261+
@click="oauth2Login(provider)"
262+
>
263+
<span class="icon">
264+
<i :class="'mdi mdi-18px mdi-' + provider.registrationId" />
265+
</span>
266+
{{ t("labels.social_login", {provider: provider.registrationId}) }}
267+
</button>
268+
</div>
221269
<progress
222270
v-if="progress"
223271
class="animate-pulse progress progress-success mt-5"

src/jelu-ui/src/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@
116116
"apply" : "apply",
117117
"loading": "loading",
118118
"pick_camera": "pick camera",
119-
"add_narrator": "Add a narrator"
119+
"add_narrator": "Add a narrator",
120+
"social_login": "sign in with {provider}"
120121
},
121122
"settings" : {
122123
"pick_language" : "Pick your language",
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface OAuth2ClientDto {
2+
name: string,
3+
registrationId: string,
4+
}

src/jelu-ui/src/router.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createRouter, createWebHistory } from 'vue-router'
22
import store from './store'
33
import AdminBaseVue from './components/AdminBase.vue'
4+
import urls from './urls'
45

56
const isLogged = () => {
67
if (!store.getters.getLogged) {
@@ -141,6 +142,21 @@ router.beforeEach((to, from, next) => {
141142
console.log(`from : ${from.name?.toString()}`)
142143
console.log(from)
143144
console.log(store.getters.getLogged)
145+
146+
if (window.opener !== null &&
147+
window.name === 'oauth2Login' &&
148+
to.query.server_redirect === 'Y'
149+
) {
150+
if (!to.query.error) {
151+
// authentication succeeded, we redirect the parent window so that it can login via cookie
152+
window.opener.location.href = urls.BASE_URL
153+
} else {
154+
// authentication failed, we cascade the error message to the parent
155+
window.opener.location.href = window.location
156+
}
157+
// we can close the popup
158+
window.close()
159+
}
144160
if (from.name == undefined
145161
&& from.matched.length < 1) {
146162
console.log('undefined wanting to go to ' + to.name?.toString())

src/jelu-ui/src/services/DataService.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { MetadataRequest } from "../model/MetadataRequest";
2525
import { Series, SeriesUpdate } from "../model/Series";
2626
import { DirectoryListing } from "../model/DirectoryListing";
2727
import { BookQuote, CreateBookQuoteDto, UpdateBookQuoteDto } from "../model/BookQuote";
28+
import urls from "../urls";
29+
import { OAuth2ClientDto } from "../model/oauth-client-dto";
2830

2931
class DataService {
3032

@@ -80,25 +82,9 @@ class DataService {
8082

8183
private API_BOOK_QUOTES = '/book-quotes';
8284

83-
private MODE: string;
84-
85-
private BASE_URL: string;
86-
8785
constructor() {
88-
if (import.meta.env.DEV) {
89-
this.MODE = "dev"
90-
this.BASE_URL = import.meta.env.VITE_API_URL as string
91-
}
92-
else {
93-
this.MODE = "prod"
94-
this.BASE_URL = window.location.origin
95-
this.BASE_URL.endsWith("/") ? this.BASE_URL = this.BASE_URL + "api/v1"
96-
: this.BASE_URL = this.BASE_URL + "/api/v1"
97-
}
98-
console.log(`running in ${this.MODE} mode at ${this.BASE_URL}`)
99-
10086
this.apiClient = axios.create({
101-
baseURL: this.BASE_URL,
87+
baseURL: urls.API_URL,
10288
headers: {
10389
"Content-type": "application/json",
10490
'X-Requested-With': 'XMLHttpRequest'
@@ -1858,6 +1844,23 @@ class DataService {
18581844
}
18591845
}
18601846

1847+
oauth2Providers = async () => {
1848+
try {
1849+
const response = await this.apiClient.get<Array<OAuth2ClientDto>>("/oauth2/providers");
1850+
console.log("called oauth providers")
1851+
console.log(response)
1852+
return response.data;
1853+
}
1854+
catch (error) {
1855+
if (axios.isAxiosError(error) && error.response) {
1856+
console.log("error axios " + error.response.status + " " + error.response.data.error)
1857+
}
1858+
console.log("error oauth providers " + (error as AxiosError).code)
1859+
throw new Error("error oauth providers " + error)
1860+
}
1861+
}
1862+
1863+
18611864
}
18621865

18631866
export default new DataService()

src/jelu-ui/src/urls.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
class Urls {
2+
3+
public MODE: string;
4+
5+
public BASE_URL: string;
6+
7+
public API_URL: string
8+
9+
constructor() {
10+
if (import.meta.env.DEV) {
11+
this.MODE = "dev"
12+
this.BASE_URL = import.meta.env.VITE_API_URL as string
13+
this.API_URL = this.BASE_URL
14+
}
15+
else {
16+
this.MODE = "prod"
17+
this.BASE_URL = window.location.origin.endsWith("/") ? window.location.origin.slice(0, -1) : window.location.origin
18+
this.API_URL = this.BASE_URL + "/api/v1"
19+
}
20+
console.log(`running in ${this.MODE} mode at ${this.BASE_URL} and ${this.API_URL}`)
21+
}
22+
23+
}
24+
export default new Urls()

src/main/kotlin/io/github/bayang/jelu/config/JeluProperties.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ data class JeluProperties(
5959
data class Auth(
6060
var ldap: Ldap,
6161
var proxy: Proxy,
62+
var oauth2AccountCreation: Boolean = false,
63+
var oidcEmailVerification: Boolean = true,
6264
)
6365

6466
data class Ldap(

src/main/kotlin/io/github/bayang/jelu/config/SecurityConfig.kt

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,15 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
1111
import org.springframework.security.config.http.SessionCreationPolicy
1212
import org.springframework.security.core.userdetails.UserDetailsService
1313
import org.springframework.security.crypto.password.PasswordEncoder
14+
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest
15+
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
16+
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
17+
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService
18+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
19+
import org.springframework.security.oauth2.core.oidc.user.OidcUser
20+
import org.springframework.security.oauth2.core.user.OAuth2User
1421
import org.springframework.security.web.SecurityFilterChain
22+
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler
1523
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
1624
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
1725

@@ -24,8 +32,13 @@ class SecurityConfig(
2432
private val passwordEncoder: PasswordEncoder,
2533
private val authHeaderFilter: AuthHeaderFilter?,
2634
private val userAgentWebAuthenticationDetailsSource: WebAuthenticationDetailsSource,
35+
private val oauth2UserService: OAuth2UserService<OAuth2UserRequest, OAuth2User>,
36+
private val oidcUserService: OAuth2UserService<OidcUserRequest, OidcUser>,
37+
clientRegistrationRepository: InMemoryClientRegistrationRepository?,
2738
) {
2839

40+
private val oauth2Enabled = clientRegistrationRepository != null
41+
2942
@Bean
3043
@Throws(Exception::class)
3144
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
@@ -40,6 +53,8 @@ class SecurityConfig(
4053
// only apply security to those endpoints
4154
it.requestMatchers(
4255
"/api/**",
56+
"/oauth2/authorization/**",
57+
"/login/oauth2/code/**",
4358
)
4459
}
4560
.authorizeHttpRequests {
@@ -48,6 +63,7 @@ class SecurityConfig(
4863
"/api/v1/setup/status",
4964
"/api/v1/server-settings",
5065
"/api/v1/reviews/**",
66+
"/api/v1/oauth2/providers",
5167
).permitAll()
5268
it.requestMatchers(HttpMethod.GET, "/api/v1/reviews/**").permitAll()
5369
it.requestMatchers(HttpMethod.GET, "/api/v1/books/**").permitAll()
@@ -87,6 +103,29 @@ class SecurityConfig(
87103
if (properties.auth.proxy.enabled) {
88104
http.addFilterBefore(authHeaderFilter, UsernamePasswordAuthenticationFilter::class.java)
89105
}
106+
if (oauth2Enabled) {
107+
http.oauth2Login { oauth2 ->
108+
oauth2.userInfoEndpoint {
109+
it.userService(oauth2UserService)
110+
it.oidcUserService(oidcUserService)
111+
}
112+
oauth2.authenticationDetailsSource(userAgentWebAuthenticationDetailsSource)
113+
oauth2
114+
.loginPage("/login")
115+
.defaultSuccessUrl("/?server_redirect=Y", true)
116+
.failureHandler { request, response, exception ->
117+
val errorMessage =
118+
when (exception) {
119+
is OAuth2AuthenticationException -> exception.error.errorCode
120+
else -> exception.message
121+
}
122+
val url = "/login?server_redirect=Y&error=$errorMessage"
123+
SimpleUrlAuthenticationFailureHandler(url).onAuthenticationFailure(request, response, exception)
124+
}
125+
oauth2.redirectionEndpoint {
126+
}
127+
}
128+
}
90129
return http.build()
91130
}
92131
}

src/main/kotlin/io/github/bayang/jelu/config/SessionConfig.kt

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import org.springframework.session.config.SessionRepositoryCustomizer
1717
import org.springframework.session.jdbc.JdbcIndexedSessionRepository
1818
import org.springframework.session.jdbc.config.annotation.web.http.EnableJdbcHttpSession
1919
import org.springframework.session.security.SpringSessionBackedSessionRegistry
20-
import org.springframework.session.web.http.HeaderHttpSessionIdResolver
20+
import org.springframework.session.web.http.CookieSerializer
21+
import org.springframework.session.web.http.DefaultCookieSerializer
2122
import org.springframework.session.web.http.HttpSessionIdResolver
2223
import java.io.IOException
2324
import java.io.InputStream
@@ -36,9 +37,22 @@ class SessionConfig : BeanClassLoaderAware {
3637
"DELETE FROM %TABLE_NAME% WHERE PRIMARY_ID IN (SELECT SESSION_PRIMARY_ID FROM %TABLE_NAME%_ATTRIBUTES WHERE ATTRIBUTE_NAME = 'org.springframework.session.security.SpringSessionBackedSessionInformation.EXPIRED' AND ATTRIBUTE_BYTES = 'true') OR EXPIRY_TIME < ?\n"
3738

3839
@Bean
39-
fun httpSessionIdResolver(): HttpSessionIdResolver {
40-
return HeaderHttpSessionIdResolver.xAuthToken()
41-
}
40+
fun sessionCookieName() = "SESSION"
41+
42+
@Bean
43+
fun sessionHeaderName() = "X-Auth-Token"
44+
45+
@Bean
46+
fun httpSessionIdResolver(
47+
sessionHeaderName: String,
48+
cookieSerializer: CookieSerializer,
49+
): HttpSessionIdResolver = SmartHttpSessionIdResolver(sessionHeaderName, cookieSerializer)
50+
51+
@Bean
52+
fun cookieSerializer(sessionCookieName: String): CookieSerializer =
53+
DefaultCookieSerializer().apply {
54+
setCookieName(sessionCookieName)
55+
}
4256

4357
@Bean
4458
fun sessionRegistry(sessionRepository: FindByIndexNameSessionRepository<*>): SessionRegistry =

0 commit comments

Comments
 (0)