Skip to content

Commit 8b51908

Browse files
authored
Merge pull request #152 from devondragon/issue-137-Add-Keycloak-Authentication-Support
Issue 137 add keycloak authentication support
2 parents 136457d + f4c771e commit 8b51908

11 files changed

Lines changed: 297 additions & 21 deletions

File tree

README.md

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,19 @@ Check out the [Spring User Framework Demo Application](https://github.com/devond
3939
## Features
4040

4141
- **User Registration and Authentication**
42-
- Local username/password authentication
43-
- OAuth2/SSO with Google, Facebook, and more
44-
- Email verification workflow
45-
- Password reset functionality
46-
- Account management (update profile, change password)
42+
The framework provides support for the following features:
43+
- Registration, with optional email verification.
44+
- Login and logout functionality.
45+
- Forgot password flow.
46+
- Database-backed user store using Spring JPA.
47+
- SSO support for Google
48+
- SSO support for Facebook
49+
- SSO support for Keycloak
50+
- Configuration options to control anonymous access, whitelist URIs, and protect specific URIs requiring a logged-in user session.
51+
- CSRF protection enabled by default, with example jQuery AJAX calls passing the CSRF token from the Thymeleaf page context.
52+
- Audit event framework for recording and logging security events, customizable to store audit events in a database or publish them via a REST API.
53+
- Role and Privilege setup service to define roles, associated privileges, and role inheritance hierarchy using `application.yml`.
54+
- Configurable Account Lockout after too many failed login attempts
4755

4856
- **Advanced Security**
4957
- Role and privilege-based authorization
@@ -212,12 +220,15 @@ Users can:
212220
213221
## Email Verification
214222
223+
224+
215225
The framework includes a complete email verification system:
216226
- Token generation and verification
217227
- Customizable email templates
218228
- Token expiration and renewal
219229
- Automatic account activation
220230
231+
221232
## Authentication
222233
223234
### Local Authentication
@@ -244,10 +255,21 @@ spring:
244255
client:
245256
registration:
246257
google:
247-
client-id: your-client-id
248-
client-secret: your-client-secret
249-
scope: profile,email
258+
client-id: YOUR_GOOGLE_CLIENT_ID
259+
client-secret: YOUR_GOOGLE_CLIENT_SECRET
260+
redirect-uri: "{baseUrl}/login/oauth2/code/google"
261+
facebook:
262+
client-id: YOUR_FACEBOOK_CLIENT_ID
263+
client-secret: YOUR_FACEBOOK_CLIENT_SECRET
264+
redirect-uri: "{baseUrl}/login/oauth2/code/facebook"
265+
keycloak:
266+
client-id: YOUR_KEYCLOAK_CLIENT_ID
267+
client-secret: YOUR_KEYCLOAK_CLIENT_SECRET
268+
redirect-uri: "{baseUrl}/login/oauth2/code/keycloak"
269+
250270
```
271+
For public OAuth you will need a public hostname and HTTPS enabled. You can use ngrok or Cloudflare tunnels to create a public hostname and tunnel to your local machine during development. You can then use the ngrok hostname in your Google, Facebook and Keycloak developer console configuration.
272+
251273

252274
## Extensibility
253275

@@ -273,6 +295,11 @@ public class CustomUserProfileService implements UserProfileService<CustomUserPr
273295
```
274296
Read more in the [Profile Guide](PROFILE.md).
275297

298+
299+
### SSO OAuth2 with Google and Facebook
300+
The framework supports SSO OAuth2 with Google, Facebook and Keycloak. To enable this you need to configure the client id and secret for each provider. This is done in the application.yml (or application.properties) file using the [Spring Security OAuth2 properties](https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html). You can see the example configuration in the Demo Project's `application.yml` file.
301+
302+
276303
## Examples
277304

278305
For complete working examples, check out the [Spring User Framework Demo Application](https://github.com/devondragon/SpringUserFrameworkDemoApp).

db-scripts/mariadb-schema.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ CREATE TABLE `user_account` (
6464
`last_name` VARCHAR(255) DEFAULT NULL,
6565
`locked` BIT(1) NOT NULL,
6666
`password` VARCHAR(60) DEFAULT NULL,
67-
`provider` ENUM('LOCAL','FACEBOOK','GOOGLE','APPLE') DEFAULT NULL,
67+
`provider` ENUM('LOCAL','FACEBOOK','GOOGLE','APPLE','KEYCLOAK') DEFAULT NULL,
6868
`registration_date` DATETIME(6) DEFAULT NULL,
6969
`failed_login_attempts` INT(11) NOT NULL,
7070
`locked_date` DATETIME(6) DEFAULT NULL,

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version=3.1.2-SNAPSHOT
1+
version=3.2.0-SNAPSHOT

mise.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
[tools]
2+
java = "17"
23
python = "3.13"

src/main/java/com/digitalsanctuary/spring/user/controller/UserPageController.java

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,15 @@ public class UserPageController {
2727
@Value("${user.registration.googleEnabled}")
2828
private boolean googleEnabled;
2929

30+
@Value("${user.registration.keycloakEnabled}")
31+
private boolean keycloakEnabled;
32+
3033
/**
3134
* Login Page.
3235
*
3336
* @param userDetails the user details
34-
* @param session the session
35-
* @param model the model
37+
* @param session the session
38+
* @param model the model
3639
*
3740
* @return the string
3841
*/
@@ -45,15 +48,16 @@ public String login(@AuthenticationPrincipal DSUserDetails userDetails, HttpSess
4548
}
4649
model.addAttribute("googleEnabled", googleEnabled);
4750
model.addAttribute("facebookEnabled", facebookEnabled);
51+
model.addAttribute("keycloakEnabled", keycloakEnabled);
4852
return "user/login";
4953
}
5054

5155
/**
5256
* Register Page.
5357
*
5458
* @param userDetails the user details
55-
* @param session the session
56-
* @param model the model
59+
* @param session the session
60+
* @param model the model
5761
* @return the string
5862
*/
5963
@GetMapping("${user.security.registrationURI:/user/register.html}")
@@ -65,6 +69,7 @@ public String register(@AuthenticationPrincipal DSUserDetails userDetails, HttpS
6569
}
6670
model.addAttribute("googleEnabled", googleEnabled);
6771
model.addAttribute("facebookEnabled", facebookEnabled);
72+
model.addAttribute("keycloakEnabled", keycloakEnabled);
6873
return "user/register";
6974
}
7075

@@ -82,8 +87,8 @@ public String registrationPending() {
8287
* Registration complete.
8388
*
8489
* @param userDetails the user details
85-
* @param session the session
86-
* @param model the model
90+
* @param session the session
91+
* @param model the model
8792
*
8893
* @return the string
8994
*/
@@ -134,10 +139,11 @@ public String forgotPasswordChange() {
134139
return "user/forgot-password-change";
135140
}
136141

142+
137143
/**
138144
* @param userDetails the user details
139-
* @param request the request
140-
* @param model the model
145+
* @param request the request
146+
* @param model the model
141147
* @return String
142148
*/
143149
@GetMapping("${user.security.updateUserURI:/user/update-user.html}")

src/main/java/com/digitalsanctuary/spring/user/persistence/model/User.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ public enum Provider {
4040
/**
4141
* Login using Apple as the authentication provider.
4242
*/
43-
APPLE
43+
APPLE,
44+
45+
/**
46+
* Login using Keycloak as the authentication provider.
47+
*/
48+
KEYCLOAK
4449
}
4550

4651
/** The id. */

src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import java.util.Collections;
77
import java.util.List;
88
import java.util.stream.Collectors;
9+
10+
import com.digitalsanctuary.spring.user.service.DSOidcUserService;
911
import org.springframework.beans.factory.annotation.Value;
1012
import org.springframework.context.ApplicationEventPublisher;
1113
import org.springframework.context.annotation.Bean;
@@ -113,6 +115,7 @@ public class WebSecurityConfig {
113115
private final LogoutSuccessService logoutSuccessService;
114116
private final RolesAndPrivilegesConfig rolesAndPrivilegesConfig;
115117
private final DSOAuth2UserService dsOAuth2UserService;
118+
private final DSOidcUserService dsOidcUserService;
116119

117120
/**
118121
*
@@ -183,7 +186,10 @@ private void setupOAuth2(HttpSecurity http) throws Exception {
183186
request.getSession().setAttribute("error.message", exception.getMessage());
184187
response.sendRedirect(loginPageURI);
185188
// handler.onAuthenticationFailure(request, response, exception);
186-
}).userInfoEndpoint(userInfo -> userInfo.userService(dsOAuth2UserService)));
189+
}).userInfoEndpoint(userInfo -> {
190+
userInfo.userService(dsOAuth2UserService);
191+
userInfo.oidcUserService(dsOidcUserService);
192+
}));
187193
}
188194

189195
// Commenting this out to try adding /error to the unprotected URIs list instead
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package com.digitalsanctuary.spring.user.service;
2+
3+
import com.digitalsanctuary.spring.user.persistence.model.User;
4+
import com.digitalsanctuary.spring.user.persistence.repository.UserRepository;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
8+
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
9+
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
10+
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
11+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
12+
import org.springframework.security.oauth2.core.OAuth2Error;
13+
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
14+
import org.springframework.security.oauth2.core.user.OAuth2User;
15+
import org.springframework.stereotype.Service;
16+
17+
/**
18+
*
19+
* This class is an implementation of the OAuth2UserService interface that is used to handle Oidc logins for a Spring Security application. It
20+
* provides methods to handle successful Oidc logins, register new users with Oidc accounts, update existing users with new Oidc information,
21+
* and retrieve user information from an OidcUser object. This service is used in conjunction with Spring Security's OAuth2LoginConfigurer to enable
22+
* Oidc login functionality for a web application. The OAuth2LoginConfigurer configures Spring Security to authenticate users with an Oidc
23+
* provider, and uses this service to handle the authentication process and retrieve user information from the provider. This class is annotated with
24+
* the @Service annotation to indicate that it is a Spring service that should be automatically detected and instantiated by the Spring container.
25+
*
26+
* @see org.springframework.security.oauth2.client.registration.ClientRegistration
27+
* @see OAuth2UserService
28+
* @see OAuth2UserRequest
29+
* @see OAuth2User
30+
* @see org.springframework.security.oauth2.core.user.DefaultOAuth2User
31+
* @see org.springframework.security.oauth2.core.user.OAuth2UserAuthority
32+
* @see org.springframework.security.core.userdetails.UserDetails
33+
* @see org.springframework.security.core.userdetails.User
34+
* @see User
35+
* @see UserRepository
36+
*/
37+
@Slf4j
38+
@Service
39+
@RequiredArgsConstructor
40+
public class DSOidcUserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
41+
42+
/** The user repository. */
43+
private final UserRepository userRepository;
44+
45+
OidcUserService defaultOidcUserService = new OidcUserService();
46+
47+
/**
48+
*
49+
* Handles a successful Oidc login. If the user is already registered, updates their account with any new information from the Oidc provider.
50+
* If the user is not already registered, creates a new user account with the information from the Oidc provider and saves it to the database.
51+
*
52+
* @param registrationId The registration ID for the Oidc provider.
53+
* @param oidcUser The OidcUser object containing information about the authenticated user.
54+
* @return A User object representing the authenticated user.
55+
*/
56+
public User handleOidcLoginSuccess(String registrationId, OidcUser oidcUser) {
57+
User user = null;
58+
if (registrationId.equalsIgnoreCase("keycloak")) {
59+
user = getUserFromKeycloakOidc2User(oidcUser);
60+
} else {
61+
log.error("Sorry! Login with " + registrationId + " is not supported yet.");
62+
throw new OAuth2AuthenticationException(new OAuth2Error("Login Exception"),
63+
"Sorry! Login with " + registrationId + " is not supported yet.");
64+
}
65+
if (user == null) {
66+
log.error("handleOidcLoginSuccess: user is null");
67+
throw new OAuth2AuthenticationException(new OAuth2Error("Login Exception"),
68+
"Sorry! An error occurred while processing your login request.");
69+
}
70+
log.debug("handleOidcLoginSuccess: looking up user with email: {}", user.getEmail());
71+
User existingUser = userRepository.findByEmail(user.getEmail());
72+
log.debug("handleOidcLoginSuccess: existingUser: {}", existingUser);
73+
if (existingUser != null && registrationId != null) {
74+
log.debug("handleOidcLoginSuccess: existingUser.getProvider(): {}", existingUser.getProvider());
75+
// If the user is already registered with a different auth provider (OAuth2 or Local), throw an exception.
76+
if (!existingUser.getProvider().toString().equals(registrationId.toUpperCase())) {
77+
log.debug("handleOidcLoginSuccess: ERROR! existingUser.getProvider(): {}", existingUser.getProvider());
78+
throw new OAuth2AuthenticationException(new OAuth2Error("User Registered With Alternate Provider"),
79+
"Looks like you're signed up with your " + existingUser.getProvider() + " account. Please use your "
80+
+ existingUser.getProvider() + " account to log in.");
81+
}
82+
existingUser = updateExistingUser(existingUser, user);
83+
return userRepository.save(existingUser);
84+
} else {
85+
log.debug("handleOidcLoginSuccess: registering new user with email: {}", user.getEmail());
86+
user = registerNewOidcUser(registrationId, user);
87+
return user;
88+
}
89+
}
90+
91+
/**
92+
*
93+
* Registers a new user with an Oidc account. Creates a new user account with the information from the Oidc provider and saves it to the
94+
* database.
95+
*
96+
* @param registrationId The registration ID for the Oidc provider.
97+
* @param user The User object representing the authenticated user.
98+
* @return A User object representing the newly registered user.
99+
*/
100+
private User registerNewOidcUser(String registrationId, User user) {
101+
User.Provider provider = User.Provider.valueOf(registrationId.toUpperCase());
102+
user.setProvider(provider);
103+
// user.setRoles(Collections.singletonList(roleRepository.findByName(RoleName.ROLE_USER)));
104+
// We will trust OAuth2 providers to provide us with a verified email address.
105+
user.setEnabled(true);
106+
return userRepository.save(user);
107+
}
108+
109+
/**
110+
*
111+
* Updates an existing user's account with any new information from the Oidc provider.
112+
*
113+
* @param existingUser The existing User object representing the user to be updated.
114+
* @param user The User object representing the authenticated user.
115+
* @return The updated User object.
116+
*/
117+
private User updateExistingUser(User existingUser, User user) {
118+
existingUser.setFirstName(user.getFirstName());
119+
existingUser.setLastName(user.getLastName());
120+
return existingUser;
121+
}
122+
123+
/**
124+
*
125+
* Retrieves user information from a Keycloak OidcUser object.
126+
*
127+
* @param principal The OidcUser object containing information about the authenticated user.
128+
* @return A User object representing the authenticated user.
129+
*/
130+
public User getUserFromKeycloakOidc2User(OidcUser principal) {
131+
log.debug("Getting user info from Keycloak Oidc provider with principal: {}", principal);
132+
if (principal == null) {
133+
return null;
134+
}
135+
log.debug("Principal attributes: {}", principal.getAttributes());
136+
User user = new User();
137+
/* user.setEmail(principal.getAttribute("email"));
138+
user.setFirstName(principal.getAttribute("given_name"));
139+
user.setLastName(principal.getAttribute("family_name"));*/
140+
user.setEmail(principal.getEmail());
141+
user.setFirstName(principal.getGivenName());
142+
user.setLastName(principal.getFamilyName());
143+
user.setProvider(User.Provider.KEYCLOAK);
144+
return user;
145+
}
146+
147+
148+
/**
149+
*
150+
* Loads user information from an Oidc provider and creates a UserDetails object representing the authenticated user.
151+
*
152+
* @param userRequest The OidcUserRequest object containing information about the user request.
153+
* @return A UserDetails object representing the authenticated user.
154+
* @throws OAuth2AuthenticationException If there is an error authenticating the user with the OAuth2 provider.
155+
*/
156+
@Override
157+
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
158+
log.debug("Loading user from OAuth2 provider with userRequest: {}", userRequest);
159+
OidcUser user = defaultOidcUserService.loadUser(userRequest);
160+
String registrationId = userRequest.getClientRegistration().getRegistrationId();
161+
log.debug("registrationId: " + registrationId);
162+
User dbUser = handleOidcLoginSuccess(registrationId, user);
163+
DSUserDetails dsUserDetails = DSUserDetails.builder()
164+
.user(dbUser)
165+
.oidcUserInfo(user.getUserInfo())
166+
.oidcIdToken(user.getIdToken())
167+
.grantedAuthorities(user.getAuthorities())
168+
.build();
169+
return dsUserDetails;
170+
}
171+
}

0 commit comments

Comments
 (0)