一、项目准备:
1.创建一个Springboot项目。
2.注册一个微软的Azure AD服务,并且注册应用,创建用户。
springboot项目pom文件如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.7.2</version><relativePath/></parent><groupId>com.framework</groupId><artifactId>security-azure-test</artifactId><version>1.0-SNAPSHOT</version><description>Demo project for Spring Boot</description><properties><java.version>11</java.version><spring-cloud-azure.version>4.7.0</spring-cloud-azure.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- Spring Security --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><!-- 引入前端模板依赖 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-client</artifactId></dependency><dependency><groupId>com.azure.spring</groupId><artifactId>spring-cloud-azure-starter-active-directory</artifactId></dependency><!--引入jpa --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency><!--数据库链接驱动--><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency><!-- 日志系统 --><dependency><groupId>org.slf4j</groupId><artifactId>slf4j-api</artifactId><version>1.7.25</version></dependency><dependency><groupId>org.slf4j</groupId><artifactId>slf4j-log4j12</artifactId><version>1.7.25</version></dependency><dependency><groupId>org.apache.commons</groupId><artifactId>commons-lang3</artifactId><version>3.5</version></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.2</version><scope>provided</scope></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency></dependencies><dependencyManagement><dependencies><dependency><groupId>com.azure.spring</groupId><artifactId>spring-cloud-azure-dependencies</artifactId><version>${spring-cloud-azure.version}</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement>
</project>
二、构建SpringSecurityConfig
这里在HttpSecurity需要配置常规登录选项,并且同时使用oauth2Login登录选项。
1.在authorizationManagerBuilder中构建自定义的一个Provider。
2.在httpSecurity构建常规账号密码登录的选项。
3.在httpSecurity构建oauth2login授权登录选项。
4.在httpSecurity构建Oauth2LoginConfigurer,并且实现自定义实现Oauth2UserService,来完成用户角色权限的构建。
5.在httpSecurity添加授权认证成功后的handler实现,用于重定向授权后的登录成功接口。
代码如下:
/*** @Author: LongGE* @Date: 2023-05-12* @Description:*/
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {/*** 在授权成功后查询本地数据库用户以及角色和权限信息。*/@Autowiredprivate CustomOidcService customOidcService;/*** 自定义的provider,用于账号密码登录*/@Autowiredprivate CustomDaoAuthenticationProvider customDaoAuthenticationProvider;/*** 自定义在授权成功后,控制授权登录成功后跳转本地项目的页面和接口,并且也可以用于添加session和cookie*/@Autowiredprivate CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;/*** 密码校对验证器* @return*/@Beanpublic BCryptPasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}/*** 构建manager认证器* @return* @throws Exception*/@Beanpublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}/*** 添加自定义的provider,通过自定义的provider可以实现不同的账号密码登录* @param authenticationManagerBuilder* @throws Exception*/@Overridepublic void configure(AuthenticationManagerBuilder authenticationManagerBuilder) {authenticationManagerBuilder.authenticationProvider(customDaoAuthenticationProvider);}/*** 构建HttpSecurity 认证* @param http* @throws Exception*/@Overrideprotected void configure(HttpSecurity http) throws Exception {http.csrf().disable().authorizeRequests().antMatchers("/login").permitAll().antMatchers("/login/oauth2/code/azure").permitAll().antMatchers("/AuthLoginController/**").permitAll().anyRequest().authenticated().and()//构建UsernamePasswordAuthenticationFilter拦截器.formLogin().loginPage("/login").permitAll().and()//构建OAuth2LoginConfigurer,用于OAuth2Login授权登录.oauth2Login().loginPage("/login").permitAll()//授权服务器UserInfo端点的配置选项。.userInfoEndpoint()//添加一个自定义的OAuth2UserService,用于实现授权成功后对用户信息和角色权限信息的封装.oidcUserService(customOidcService).and()//添加一个Handler,用于授权成功后,对跳转登录成功后的重定向页面进行指向,也可以用于添加授权登录成功的sessionID和Cookie.successHandler(customAuthenticationSuccessHandler);}/*** 过滤静态页面和图片信息,不让Filter拦截* @param web*/@Overridepublic void configure(WebSecurity web) {web.ignoring().antMatchers("/assets/images/**");}
}
三、自定义CustomDaoAuthenticationProvider
自己实现AuthenticationProvider接口,这样可以根据自己传入的不同TAuthenticationToken去执行自己定义Provider,可以更加灵活自主的实现登录业务逻辑。
/*** @Author: LongGE* @Date: 2023-04-10* @Description:*/
@Component
@Slf4j
public class CustomDaoAuthenticationProvider implements AuthenticationProvider {@Autowiredprivate CustomUserDetailsServiceImpl customUserDetailsService;@Overridepublic Authentication authenticate(Authentication authentication) throws AuthenticationException {CustomUserDetails customUserDetails = (CustomUserDetails) customUserDetailsService.loadUserByUsername(authentication.getPrincipal().toString());CustomDaoUsernameToken customDaoUsernameToken = new CustomDaoUsernameToken(customUserDetails,null, customUserDetails.getAuthorities());return customDaoUsernameToken;}/*** As a business judgment, built in the controller,* the judgment is made here so that you can call the AuthenticationProvider that encapsulates the corresponding one in ProviderManeger* @param authentication* @return*/@Overridepublic boolean supports(Class<?> authentication) {return CustomDaoUsernameToken.class.isAssignableFrom(authentication);}
}
四、自定义CustomDaoUsernameToken
继承AbstractAuthenticationToken抽象类,自己定义一个AuthenticationToken类,这样在登录时候调用authenticate()方法时候传入自己定义的AuthenticationToken就可以,这样ProviderManager类就会自动匹配自定义的Provider去实现登录认证逻辑。
/*** @Author: LongGE* @Date: 2023-04-10* @Description:*/
public class CustomDaoUsernameToken extends AbstractAuthenticationToken {private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;private final Object principal;private Object credentials;public CustomDaoUsernameToken(Object principal, Object credentials) {super(null);this.principal = principal;this.credentials = credentials;setAuthenticated(false);}public CustomDaoUsernameToken(Object principal, Object credentials,Collection<? extends GrantedAuthority> authorities) {super(authorities);this.principal = principal;this.credentials = credentials;super.setAuthenticated(true);}@Overridepublic Object getCredentials() {return credentials;}@Overridepublic Object getPrincipal() {return principal;}@Overridepublic void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {Assert.isTrue(!isAuthenticated,"Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");super.setAuthenticated(false);}@Overridepublic void eraseCredentials() {super.eraseCredentials();this.credentials = null;}
}
五、自定义CustomUserDetailsServiceImpl
自定义的登录认证,实现UserDetailService接口,在provider中会调用自定义的CustomUserDetailsServiceImpl类的loadUserByUsername()方法来认证账号是否存在并且查询用户角色以及权限信息,并且封装到了Security的上下文中,后续方法可以直接在上线文中回去这些用户信息。
@Service
public class CustomUserDetailsServiceImpl implements UserDetailsService {private static final Logger LOGGER = LoggerFactory.getLogger(CustomUserDetailsServiceImpl.class);@Autowiredprivate SystemUserDao systemUserDao;@Overridepublic UserDetails loadUserByUsername(String username) throws BadCredentialsException {LOGGER.debug("CustomUserDetailsServiceImpl: " + ":loadUserByUsername()={}", username);User user = new User();Set<Authority> hasAuthority = new HashSet<>();SystemUser systemUser = systemUserDao.queryByUsername(username);user.setId(systemUser.getId());user.setUsername(username);user.setEnabled(true);user.setAuthorities(hasAuthority);return new CustomUserDetails(user);}
}
六、自定义CustomOidcService
在AzureAD授权认证后,返回给我们用户信息,由OAuth2LoginAuthenticationFilter拦截器拦截,调用attemptAuthentication()方法,在此方法中会获取ProviderManager类,在调用ProviderManager的authenticate()方法进行认证,传入的参数是OAuth2LoginAuthenticationToken类型的token,在封装在ProviderManager中只有OidcAuthorizationCodeAuthenticationProvider类满足认证条件,在此provider的authenticate()方法中会调用自定义的CustomOidcService类的loadUser()方法进行认证,传入的参数是OidcUserRequest类型,在这里通过userRequest.getIdToken();方法获取OidcIdToken,这里封装AzureAD中的基础用户信息,通过用户信息去数据库查询用户角色和权限,将角色和权限封装到Security的上下文中,并且也可以封装到redis等缓存中,方便后续使用。
/*** @Author: LongGE* @Date: 2023-05-15* @Description:*/
@Slf4j
@Service
public class CustomOidcService implements OAuth2UserService<OidcUserRequest, OidcUser> {@Autowiredprivate SystemUserDao systemUserDao;@Overridepublic OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {OidcIdToken idToken = userRequest.getIdToken();log.info("打印请求参数: {}",idToken);Set<String> authorityStrings = new HashSet<>();Set<SimpleGrantedAuthority> authorities = authorityStrings.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet());SystemUser systemUser = systemUserDao.queryByUsername(userRequest.getIdToken().getPreferredUsername());CustomOidcUser customOidcUser = new CustomOidcUser(authorities, idToken, systemUser);return customOidcUser;}
}
七、自定义CustomAuthenticationSuccessHandler
在第六步认证成功后,AbstractAuthenticationProcessingFilter拦截器,会调用AuthenticationSuccessHandler接口的successfulAuthentication()方法,自定义的CustomAuthenticationSuccessHandler类是实现了这个接口的successfulAuthentication()方法,实现此方法主要是用户在用户通过AzureAD授权登录成功后,可以控制用户去加载登录成功后的浏览页面,并且还需要给前端返回的Response中添加Http请求头中添加cookie,这样以后前端每次访问后端接口,都携带此cookie那么就可以通过拦截器去确认用户是否登录。
/*** @Author: LongGE* @Date: 2023-05-22* @Description: 用户认证成功后处理后续重定向操作的* Strategy used to handle a successful user authentication.* <p>* Implementations can do whatever they want but typical behaviour would be to control the* navigation to the subsequent destination (using a redirect or a forward). For example,* after a user has logged in by submitting a login form, the application needs to decide* where they should be redirected to afterwards (see* {@link AbstractAuthenticationProcessingFilter} and subclasses). Other logic may also be* included if required.*/
@Service
@Slf4j
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {@Overridepublic void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {onAuthenticationSuccess(request, response, authentication);chain.doFilter(request, response);}@Overridepublic void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {CustomOidcUser customOidcUser = (CustomOidcUser)authentication.getPrincipal();SystemUser user = customOidcUser.getSystemUser();// Session IDString sessionId = UUID.randomUUID().toString();Map<String,Object> tokenClaims = new HashMap<>();tokenClaims.put("SessionId", sessionId);//Create token//Token newAccessToken = tokenProvider.generateAccessToken(user.getUsername(), tokenClaims, authentication, tokenExpirationSec);//Enter token log//customBaseService.logToken(newAccessToken);/* if(user != null && user.getId() != null) {//Add Session Id to UserSession DBcustomBaseService.addUserSession(user.getId(), sessionId, request);//Add Redis cache with expiration timecustomBaseService.addRedisUserSession(user.getId(), user.getUsername());}//Set the redirect path and add the token cache to the cookieresponse.addHeader("Set-Cookie", cookieUtil.createAccessTokenCookie(newAccessToken.getTokenValue(),newAccessToken.getDuration()).toString());*/response.sendRedirect("/index");}
}
八、登录页面
登录页面支持简单的账号密码登录,同时也支持AzureAD的授权方式登录。
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org/" lang="en"><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><!-- Tell the browser to be responsive to screen width --><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="icon" type="image/png" sizes="16x16" href="/assets/images/favicon.png"><head><meta charset="UTF-8"><title>Title</title></head><body><h1>用户登录</h1><!--th:action="@{/AuthLoginController/doLogin}" method="post"--><form id="loginform"><div id="divError" class="input-group mb-12 aui-message aui-message-error" style="display: none"><span style="color: red" id="errorMessage"></span></div><div class="input-group mb-3"><div class="input-group-prepend"><span class="input-group-text" id="basic-addon1">用户名:</span></div><input type="text" class="form-control form-control-lg"placeholder="Username" id="username" name="username"aria-label="username" aria-describedby="basic-addon1" required></div><div class="input-group mb-3"><div class="input-group-prepend"><span class="input-group-text" id="basic-addon2">密码:</span></div><input type="password" class="form-control form-control-lg"placeholder="Password" id="password" name="password"aria-label="Password" aria-describedby="basic-addon1" required></div><div class="form-group text-center"><div class="col-xs-12 pb-3"><!-- input class="btn btn-block btn-lg btn-info" type="submit" value="Log in" /--><button id="ldaploginbtn" class="btn btn-block btn-lg btn-info"type="button">LDAP Log in</button></div></div></form><br/><br/><br/><!--<form th:action="@{/oauth2/authorization/uuc}" method="post"><input type="submit" value="UUC登录"></form>--><br/><br/><br/><form th:action="@{/oauth2/authorization/azure}" method="post"><input type="submit" value="AzureAD授权登录"></form></body><script src="/assets/libs/jquery/dist/jquery.min.js"></script><script src="/assets/libs/bootstrap/dist/js/bootstrap.min.js"></script><script src="/assets/libs/jquery/dist/jquery.serializejson.js"></script><script src="/js/common/login.js"></script>
</html>
login.js的js代码:
$(document).ready(function() {document.getElementById("password").addEventListener("keyup", function(event) {if (event.keyCode === 13) {$('#loginbtn').click();return false;}});//LDAP Login$('#ldaploginbtn').click(function() {$('#errorMessage').text('');$('#divError').hide();//Check account passwordlet $name=$('#username');let $pwd=$('#password');// 按钮点击后检查输入框是否为空,为空则找到span便签添加提示if ($name.val().length===0 || $name.val() == ("") || $pwd.val().length===0 || $pwd.val() == ("")) {$('#errorMessage').text('Please fill in the account password!');$('#divError').show();}else {var formData = $("#loginform").serializeJSON();var jsonData = JSON.stringify(formData);$.ajax({url: "AuthLoginController/doLogin",type: 'POST',data: jsonData,contentType: 'application/json; charset=utf-8',dataType: 'json',success: function(data) {if (data.status == "SUCCESS") {console.log("登录成功返回!")window.location.href = data.redirectPath;//"/index";} else {$('#errorMessage').text(data.message);$('#divError').show();}},error: function(xhr, ajaxOptions, thrownError) {swalexceptionhandler(xhr.status, xhr.responseText);}});}});
});function swalexceptionhandler(status, responseText) {if (status == "412" || status == "422") {var obj = JSON.parse(responseText);var displaymsg = "";for (let i = 0; i < obj.errors; i++) {displaymsg += obj.errorInfo[i].errCode + ":" + obj.errorInfo[i].errDescription + " (" + obj.errorInfo[i].errField + ")" + "<br>";}//swal('Validation', displaymsg, 'warning');} else {//swal('Exception', responseText, 'error');}
}
九、登录接口AuthLoginController与LoginController
LoginController:主要加载登录页面和登录成功页面。
AuthLoginController:处理简单的账号密码登录请求逻辑。
代码分别如下:
/*** @Author: LongGE* @Date: 2023-05-19* @Description:*/
@Controller
@Slf4j
public class LoginController {@RequestMapping("/login")public String loginHtml(){return "login";}@RequestMapping("/index")public String indexHtml() {log.info("发送请求违背拦截!");return "index";}
}
/*** @Author: LongGE* @Date: 2023-05-12* @Description:*/
@RestController
@RequestMapping("/AuthLoginController")
@Slf4j
public class AuthLoginController {@Autowiredprivate AuthenticationManager authenticationManager;@Autowiredprivate ServletContext context;@PostMapping("/doLogin")public ResponseEntity<LoginResponse> auth(@RequestBody LoginRequest loginRequest, HttpServletRequest request, HttpServletResponse response) {log.info("开始登录! username={}, password={}", loginRequest.getUsername(), loginRequest.getPassword());Authentication authentication = authenticationManager.authenticate(new CustomDaoUsernameToken(loginRequest.getUsername(), loginRequest.getPassword()));SecurityContextHolder.getContext().setAuthentication(authentication);log.info("登录成功! {}", authentication);HttpHeaders responseHeaders = new HttpHeaders();String loginPath = context.getContextPath() + "/index";LoginResponse loginResponse = new LoginResponse(LoginResponse.SuccessFailure.SUCCESS, "Auth successful. Tokens are created in cookie.", loginPath);return ResponseEntity.ok().headers(responseHeaders).body(loginResponse);}
}
总结:
附一张授权登录的基础流程图: