Spring Security 架構(gòu)與源碼分析
Spring Security 主要實(shí)現(xiàn)了Authentication(認(rèn)證蝎毡,解決who are you? ) 和 Access Control(訪問控制锋拖,也就是what are you allowed to do决采?谅猾,也稱為Authorization)盯滚。Spring Security在架構(gòu)上將認(rèn)證與授權(quán)分離筋帖,并提供了擴(kuò)展點(diǎn)迅耘。
核心對象
主要代碼在spring-security-core
包下面贱枣。要了解Spring Security,需要先關(guān)注里面的核心對象颤专。
SecurityContextHolder, SecurityContext 和 Authentication
SecurityContextHolder 是 SecurityContext的存放容器纽哥,默認(rèn)使用ThreadLocal 存儲(chǔ),意味SecurityContext在相同線程中的方法都可用栖秕。
SecurityContext主要是存儲(chǔ)應(yīng)用的principal信息昵仅,在Spring Security中用Authentication 來表示。
獲取principal:
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
String username = ((UserDetails)principal).getUsername();
} else {
String username = principal.toString();
}
在Spring Security中累魔,可以看一下Authentication定義:
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
/**
* 通常是密碼
*/
Object getCredentials();
/**
* Stores additional details about the authentication request. These might be an IP
* address, certificate serial number etc.
*/
Object getDetails();
/**
* 用來標(biāo)識是否已認(rèn)證摔笤,如果使用用戶名和密碼登錄,通常是用戶名
*/
Object getPrincipal();
/**
* 是否已認(rèn)證
*/
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
在實(shí)際應(yīng)用中,通常使用UsernamePasswordAuthenticationToken
:
public abstract class AbstractAuthenticationToken implements Authentication,
CredentialsContainer {
}
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
}
一個(gè)常見的認(rèn)證過程通常是這樣的垦写,創(chuàng)建一個(gè)UsernamePasswordAuthenticationToken吕世,然后交給authenticationManager認(rèn)證(后面詳細(xì)說明),認(rèn)證通過則通過SecurityContextHolder存放Authentication信息梯投。
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginVM.getUsername(), loginVM.getPassword());
Authentication authentication = this.authenticationManager.authenticate(authenticationToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
UserDetails與UserDetailsService
UserDetails 是Spring Security里的一個(gè)關(guān)鍵接口命辖,他用來表示一個(gè)principal况毅。
public interface UserDetails extends Serializable {
/**
* 用戶的授權(quán)信息,可以理解為角色
*/
Collection<? extends GrantedAuthority> getAuthorities();
/**
* 用戶密碼
*
* @return the password
*/
String getPassword();
/**
* 用戶名
* */
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
UserDetails提供了認(rèn)證所需的必要信息尔艇,在實(shí)際使用里尔许,可以自己實(shí)現(xiàn)UserDetails,并增加額外的信息终娃,比如email味廊、mobile等信息。
在Authentication中的principal通常是用戶名棠耕,我們可以通過UserDetailsService來通過principal獲取UserDetails:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
GrantedAuthority
在UserDetails里說了余佛,GrantedAuthority可以理解為角色,例如 ROLE_ADMINISTRATOR
or ROLE_HR_SUPERVISOR
窍荧。
小結(jié)
-
SecurityContextHolder
, 用來訪問SecurityContext
. -
SecurityContext
, 用來存儲(chǔ)Authentication
. -
Authentication
, 代表憑證. -
GrantedAuthority
, 代表權(quán)限. -
UserDetails
, 用戶信息. -
UserDetailsService
,獲取用戶信息.
Authentication認(rèn)證
AuthenticationManager
實(shí)現(xiàn)認(rèn)證主要是通過AuthenticationManager接口辉巡,它只包含了一個(gè)方法:
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
authenticate()方法主要做三件事:
- 如果驗(yàn)證通過,返回Authentication(通常帶上authenticated=true)蕊退。
- 認(rèn)證失敗拋出
AuthenticationException
- 如果無法確定郊楣,則返回null
AuthenticationException
是運(yùn)行時(shí)異常,它通常由應(yīng)用程序按通用方式處理,用戶代碼通常不用特意被捕獲和處理這個(gè)異常瓤荔。
AuthenticationManager
的默認(rèn)實(shí)現(xiàn)是ProviderManager
净蚤,它委托一組AuthenticationProvider
實(shí)例來實(shí)現(xiàn)認(rèn)證。
AuthenticationProvider
和AuthenticationManager
類似茉贡,都包含authenticate
,但它有一個(gè)額外的方法supports
者铜,以允許查詢調(diào)用方是否支持給定Authentication
類型:
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
boolean supports(Class<?> authentication);
}
ProviderManager包含一組AuthenticationProvider
腔丧,執(zhí)行authenticate時(shí),遍歷Providers作烟,然后調(diào)用supports愉粤,如果支持,則執(zhí)行遍歷當(dāng)前provider的authenticate方法拿撩,如果一個(gè)provider認(rèn)證成功衣厘,則break。
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
Authentication result = null;
boolean debug = logger.isDebugEnabled();
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
if (debug) {
logger.debug("Authentication attempt using "
+ provider.getClass().getName());
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException e) {
prepareException(e, authentication);
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw e;
}
catch (InternalAuthenticationServiceException e) {
prepareException(e, authentication);
throw e;
}
catch (AuthenticationException e) {
lastException = e;
}
}
if (result == null && parent != null) {
// Allow the parent to try.
try {
result = parent.authenticate(authentication);
}
catch (ProviderNotFoundException e) {
// ignore as we will throw below if no other exception occurred prior to
// calling parent and the parent
// may throw ProviderNotFound even though a provider in the child already
// handled the request
}
catch (AuthenticationException e) {
lastException = e;
}
}
if (result != null) {
if (eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
// Authentication is complete. Remove credentials and other secret data
// from authentication
((CredentialsContainer) result).eraseCredentials();
}
eventPublisher.publishAuthenticationSuccess(result);
return result;
}
// Parent was null, or didn't authenticate (or throw an exception).
if (lastException == null) {
lastException = new ProviderNotFoundException(messages.getMessage(
"ProviderManager.providerNotFound",
new Object[] { toTest.getName() },
"No AuthenticationProvider found for {0}"));
}
prepareException(lastException, authentication);
throw lastException;
}
從上面的代碼可以看出压恒, ProviderManager
有一個(gè)可選parent影暴,如果parent不為空,則調(diào)用parent.authenticate(authentication)
AuthenticationProvider
AuthenticationProvider
有多種實(shí)現(xiàn)探赫,大家最關(guān)注的通常是DaoAuthenticationProvider
型宙,繼承于AbstractUserDetailsAuthenticationProvider
,核心是通過UserDetails
來實(shí)現(xiàn)認(rèn)證,DaoAuthenticationProvider
默認(rèn)會(huì)自動(dòng)加載伦吠,不用手動(dòng)配妆兑。
先來看AbstractUserDetailsAuthenticationProvide
r魂拦,看最核心的authenticate
:
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
// 必須是UsernamePasswordAuthenticationToken
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
// 獲取用戶名
String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
: authentication.getName();
boolean cacheWasUsed = true;
// 從緩存獲取
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
// retrieveUser 抽象方法,獲取用戶
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException notFound) {
logger.debug("User '" + username + "' not found");
if (hideUserNotFoundExceptions) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
else {
throw notFound;
}
}
Assert.notNull(user,
"retrieveUser returned null - a violation of the interface contract");
}
try {
// 預(yù)先檢查搁嗓,DefaultPreAuthenticationChecks芯勘,檢查用戶是否被lock或者賬號是否可用
preAuthenticationChecks.check(user);
// 抽象方法,自定義檢驗(yàn)
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException exception) {
if (cacheWasUsed) {
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
cacheWasUsed = false;
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
else {
throw exception;
}
}
// 后置檢查 DefaultPostAuthenticationChecks腺逛,檢查isCredentialsNonExpired
postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return createSuccessAuthentication(principalToReturn, authentication, user);
}
上面的檢驗(yàn)主要基于UserDetails實(shí)現(xiàn)荷愕,其中獲取用戶和檢驗(yàn)邏輯由具體的類去實(shí)現(xiàn),默認(rèn)實(shí)現(xiàn)是DaoAuthenticationProvider屉来,這個(gè)類的核心是讓開發(fā)者提供UserDetailsService來獲取UserDetails以及 PasswordEncoder來檢驗(yàn)密碼是否有效:
private UserDetailsService userDetailsService;
private PasswordEncoder passwordEncoder;
看具體的實(shí)現(xiàn)路翻,retrieveUser
,直接調(diào)用userDetailsService獲取用戶:
protected final UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
UserDetails loadedUser;
try {
loadedUser = this.getUserDetailsService().loadUserByUsername(username);
}
catch (UsernameNotFoundException notFound) {
if (authentication.getCredentials() != null) {
String presentedPassword = authentication.getCredentials().toString();
passwordEncoder.isPasswordValid(userNotFoundEncodedPassword,
presentedPassword, null);
}
throw notFound;
}
catch (Exception repositoryProblem) {
throw new InternalAuthenticationServiceException(
repositoryProblem.getMessage(), repositoryProblem);
}
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
再來看驗(yàn)證:
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
Object salt = null;
if (this.saltSource != null) {
salt = this.saltSource.getSalt(userDetails);
}
if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
// 獲取用戶密碼
String presentedPassword = authentication.getCredentials().toString();
// 比較passwordEncoder后的密碼是否和userdetails的密碼一致
if (!passwordEncoder.isPasswordValid(userDetails.getPassword(),
presentedPassword, salt)) {
logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}
小結(jié):要自定義認(rèn)證,使用DaoAuthenticationProvider茄靠,只需要為其提供PasswordEncoder和UserDetailsService就可以了茂契。
定制 Authentication Managers
Spring Security提供了一個(gè)Builder類AuthenticationManagerBuilder
,借助它可以快速實(shí)現(xiàn)自定義認(rèn)證慨绳。
看官方源碼說明:
SecurityBuilder used to create an AuthenticationManager . Allows for easily building in memory authentication, LDAP authentication, JDBC based authentication, adding UserDetailsService , and adding AuthenticationProvider's.
AuthenticationManagerBuilder可以用來Build一個(gè)AuthenticationManager掉冶,可以創(chuàng)建基于內(nèi)存的認(rèn)證、LDAP認(rèn)證脐雪、 JDBC認(rèn)證厌小,以及添加UserDetailsService和AuthenticationProvider。
簡單使用:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class ApplicationSecurity extends WebSecurityConfigurerAdapter {
public SecurityConfiguration(AuthenticationManagerBuilder authenticationManagerBuilder, UserDetailsService userDetailsService,TokenProvider tokenProvider,CorsFilter corsFilter, SecurityProblemSupport problemSupport) {
this.authenticationManagerBuilder = authenticationManagerBuilder;
this.userDetailsService = userDetailsService;
this.tokenProvider = tokenProvider;
this.corsFilter = corsFilter;
this.problemSupport = problemSupport;
}
@PostConstruct
public void init() {
try {
authenticationManagerBuilder
.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
} catch (Exception e) {
throw new BeanInitializationException("Security configuration failed", e);
}
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(problemSupport)
.accessDeniedHandler(problemSupport)
.and()
.csrf()
.disable()
.headers()
.frameOptions()
.disable()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/register").permitAll()
.antMatchers("/api/activate").permitAll()
.antMatchers("/api/authenticate").permitAll()
.antMatchers("/api/account/reset-password/init").permitAll()
.antMatchers("/api/account/reset-password/finish").permitAll()
.antMatchers("/api/profile-info").permitAll()
.antMatchers("/api/**").authenticated()
.antMatchers("/management/health").permitAll()
.antMatchers("/management/**").hasAuthority(AuthoritiesConstants.ADMIN)
.antMatchers("/v2/api-docs/**").permitAll()
.antMatchers("/swagger-resources/configuration/ui").permitAll()
.antMatchers("/swagger-ui/index.html").hasAuthority(AuthoritiesConstants.ADMIN)
.and()
.apply(securityConfigurerAdapter());
}
}
授權(quán)與訪問控制
一旦認(rèn)證成功战秋,我們可以繼續(xù)進(jìn)行授權(quán)璧亚,授權(quán)是通過AccessDecisionManager
來實(shí)現(xiàn)的≈牛框架有三種實(shí)現(xiàn)癣蟋,默認(rèn)是AffirmativeBased,通過AccessDecisionVoter
決策狰闪,有點(diǎn)像ProviderManager
委托給AuthenticationProviders
來認(rèn)證疯搅。
public void decide(Authentication authentication, Object object,
Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
int deny = 0;
// 遍歷DecisionVoter
for (AccessDecisionVoter voter : getDecisionVoters()) {
// 投票
int result = voter.vote(authentication, object, configAttributes);
if (logger.isDebugEnabled()) {
logger.debug("Voter: " + voter + ", returned: " + result);
}
switch (result) {
case AccessDecisionVoter.ACCESS_GRANTED:
return;
case AccessDecisionVoter.ACCESS_DENIED:
deny++;
break;
default:
break;
}
}
// 一票否決
if (deny > 0) {
throw new AccessDeniedException(messages.getMessage(
"AbstractAccessDecisionManager.accessDenied", "Access is denied"));
}
// To get this far, every AccessDecisionVoter abstained
checkAllowIfAllAbstainDecisions();
}
來看AccessDecisionVoter:
boolean supports(ConfigAttribute attribute);
boolean supports(Class<?> clazz);
int vote(Authentication authentication, S object,
Collection<ConfigAttribute> attributes);
object是用戶要訪問的資源,ConfigAttribute則是訪問object要滿足的條件埋泵,通常payload是字符串幔欧,比如ROLE_ADMIN 。所以我們來看下RoleVoter的實(shí)現(xiàn)丽声,其核心就是從authentication提取出GrantedAuthority礁蔗,然后和ConfigAttribute比較是否滿足條件。
public boolean supports(ConfigAttribute attribute) {
if ((attribute.getAttribute() != null)
&& attribute.getAttribute().startsWith(getRolePrefix())) {
return true;
}
else {
return false;
}
}
public boolean supports(Class<?> clazz) {
return true;
}
public int vote(Authentication authentication, Object object,
Collection<ConfigAttribute> attributes) {
if(authentication == null) {
return ACCESS_DENIED;
}
int result = ACCESS_ABSTAIN;
// 獲取GrantedAuthority信息
Collection<? extends GrantedAuthority> authorities = extractAuthorities(authentication);
for (ConfigAttribute attribute : attributes) {
if (this.supports(attribute)) {
// 默認(rèn)拒絕訪問
result = ACCESS_DENIED;
// Attempt to find a matching granted authority
for (GrantedAuthority authority : authorities) {
// 判斷是否有匹配的 authority
if (attribute.getAttribute().equals(authority.getAuthority())) {
// 可訪問
return ACCESS_GRANTED;
}
}
}
}
return result;
}
這里要疑問雁社,ConfigAttribute哪來的瘦麸?其實(shí)就是上面ApplicationSecurity的configure里的。
web security 如何實(shí)現(xiàn)
Web層中的Spring Security(用于UI和HTTP后端)基于Servlet Filters
歧胁,下圖顯示了單個(gè)HTTP請求的處理程序的典型分層滋饲。
[圖片上傳失敗...(image-47e8d6-1528938759966)]
Spring Security通過FilterChainProxy
作為單一的Filter注冊到web層厉碟,Proxy內(nèi)部的Filter。
[圖片上傳失敗...(image-3fba6-1528938759966)]
FilterChainProxy相當(dāng)于一個(gè)filter的容器屠缭,通過VirtualFilterChain來依次調(diào)用各個(gè)內(nèi)部filter
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
if (clearContext) {
try {
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
doFilterInternal(request, response, chain);
}
finally {
SecurityContextHolder.clearContext();
request.removeAttribute(FILTER_APPLIED);
}
}
else {
doFilterInternal(request, response, chain);
}
}
private void doFilterInternal(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
FirewalledRequest fwRequest = firewall
.getFirewalledRequest((HttpServletRequest) request);
HttpServletResponse fwResponse = firewall
.getFirewalledResponse((HttpServletResponse) response);
List<Filter> filters = getFilters(fwRequest);
if (filters == null || filters.size() == 0) {
if (logger.isDebugEnabled()) {
logger.debug(UrlUtils.buildRequestUrl(fwRequest)
+ (filters == null ? " has no matching filters"
: " has an empty filter list"));
}
fwRequest.reset();
chain.doFilter(fwRequest, fwResponse);
return;
}
VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters);
vfc.doFilter(fwRequest, fwResponse);
}
private static class VirtualFilterChain implements FilterChain {
private final FilterChain originalChain;
private final List<Filter> additionalFilters;
private final FirewalledRequest firewalledRequest;
private final int size;
private int currentPosition = 0;
private VirtualFilterChain(FirewalledRequest firewalledRequest,
FilterChain chain, List<Filter> additionalFilters) {
this.originalChain = chain;
this.additionalFilters = additionalFilters;
this.size = additionalFilters.size();
this.firewalledRequest = firewalledRequest;
}
public void doFilter(ServletRequest request, ServletResponse response)
throws IOException, ServletException {
if (currentPosition == size) {
if (logger.isDebugEnabled()) {
logger.debug(UrlUtils.buildRequestUrl(firewalledRequest)
+ " reached end of additional filter chain; proceeding with original chain");
}
// Deactivate path stripping as we exit the security filter chain
this.firewalledRequest.reset();
originalChain.doFilter(request, response);
}
else {
currentPosition++;
Filter nextFilter = additionalFilters.get(currentPosition - 1);
if (logger.isDebugEnabled()) {
logger.debug(UrlUtils.buildRequestUrl(firewalledRequest)
+ " at position " + currentPosition + " of " + size
+ " in additional filter chain; firing Filter: '"
+ nextFilter.getClass().getSimpleName() + "'");
}
nextFilter.doFilter(request, response, this);
}
}
}
spring security動(dòng)態(tài)配置url權(quán)限
緣起
標(biāo)準(zhǔn)的RABC, 權(quán)限需要支持動(dòng)態(tài)配置箍鼓,spring security默認(rèn)是在代碼里約定好權(quán)限,真實(shí)的業(yè)務(wù)場景通常需要可以支持動(dòng)態(tài)配置角色訪問權(quán)限呵曹,即在運(yùn)行時(shí)去配置url對應(yīng)的訪問角色款咖。
基于spring security,如何實(shí)現(xiàn)這個(gè)需求呢奄喂?
最簡單的方法就是自定義一個(gè)Filter去完成權(quán)限判斷铐殃,但這脫離了spring security框架,如何基于spring security優(yōu)雅的實(shí)現(xiàn)呢跨新?
spring security 授權(quán)回顧
spring security 通過FilterChainProxy作為注冊到web的filter富腊,F(xiàn)ilterChainProxy里面一次包含了內(nèi)置的多個(gè)過濾器,我們首先需要了解spring security內(nèi)置的各種filter:
Alias | Filter Class | Namespace Element or Attribute |
---|---|---|
CHANNEL_FILTER | ChannelProcessingFilter | http/intercept-url@requires-channel |
SECURITY_CONTEXT_FILTER | SecurityContextPersistenceFilter | http |
CONCURRENT_SESSION_FILTER | ConcurrentSessionFilter | session-management/concurrency-control |
HEADERS_FILTER | HeaderWriterFilter | http/headers |
CSRF_FILTER | CsrfFilter | http/csrf |
LOGOUT_FILTER | LogoutFilter | http/logout |
X509_FILTER | X509AuthenticationFilter | http/x509 |
PRE_AUTH_FILTER | AbstractPreAuthenticatedProcessingFilter Subclasses | N/A |
CAS_FILTER | CasAuthenticationFilter | N/A |
FORM_LOGIN_FILTER | UsernamePasswordAuthenticationFilter | http/form-login |
BASIC_AUTH_FILTER | BasicAuthenticationFilter | http/http-basic |
SERVLET_API_SUPPORT_FILTER | SecurityContextHolderAwareRequestFilter | http/@servlet-api-provision |
JAAS_API_SUPPORT_FILTER | JaasApiIntegrationFilter | http/@jaas-api-provision |
REMEMBER_ME_FILTER | RememberMeAuthenticationFilter | http/remember-me |
ANONYMOUS_FILTER | AnonymousAuthenticationFilter | http/anonymous |
SESSION_MANAGEMENT_FILTER | SessionManagementFilter | session-management |
EXCEPTION_TRANSLATION_FILTER | ExceptionTranslationFilter | http |
FILTER_SECURITY_INTERCEPTOR | FilterSecurityInterceptor | http |
SWITCH_USER_FILTER | SwitchUserFilter | N/A |
最重要的是FilterSecurityInterceptor
域帐,該過濾器實(shí)現(xiàn)了主要的鑒權(quán)邏輯赘被,最核心的代碼在這里:
protected InterceptorStatusToken beforeInvocation(Object object) {
// 獲取訪問URL所需權(quán)限
Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
.getAttributes(object);
Authentication authenticated = authenticateIfRequired();
// 通過accessDecisionManager鑒權(quán)
try {
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException accessDeniedException) {
publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
accessDeniedException));
throw accessDeniedException;
}
if (debug) {
logger.debug("Authorization successful");
}
if (publishAuthorizationSuccess) {
publishEvent(new AuthorizedEvent(object, attributes, authenticated));
}
// Attempt to run as a different user
Authentication runAs = this.runAsManager.buildRunAs(authenticated, object,
attributes);
if (runAs == null) {
if (debug) {
logger.debug("RunAsManager did not change Authentication object");
}
// no further work post-invocation
return new InterceptorStatusToken(SecurityContextHolder.getContext(), false,
attributes, object);
}
else {
if (debug) {
logger.debug("Switching to RunAs Authentication: " + runAs);
}
SecurityContext origCtx = SecurityContextHolder.getContext();
SecurityContextHolder.setContext(SecurityContextHolder.createEmptyContext());
SecurityContextHolder.getContext().setAuthentication(runAs);
// need to revert to token.Authenticated post-invocation
return new InterceptorStatusToken(origCtx, true, attributes, object);
}
}
從上面可以看出,要實(shí)現(xiàn)動(dòng)態(tài)鑒權(quán)肖揣,可以從兩方面著手:
- 自定義SecurityMetadataSource民假,實(shí)現(xiàn)從數(shù)據(jù)庫加載ConfigAttribute
- 另外就是可以自定義accessDecisionManager,官方的UnanimousBased其實(shí)足夠使用龙优,并且他是基于AccessDecisionVoter來實(shí)現(xiàn)權(quán)限認(rèn)證的羊异,因此我們只需要自定義一個(gè)AccessDecisionVoter就可以了
下面來看分別如何實(shí)現(xiàn)。
自定義AccessDecisionManager
官方的三個(gè)AccessDecisionManager都是基于AccessDecisionVoter來實(shí)現(xiàn)權(quán)限認(rèn)證的彤断,因此我們只需要自定義一個(gè)AccessDecisionVoter就可以了野舶。
自定義主要是實(shí)現(xiàn)AccessDecisionVoter
接口,我們可以仿照官方的RoleVoter實(shí)現(xiàn)一個(gè):
public class RoleBasedVoter implements AccessDecisionVoter<Object> {
@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}
@Override
public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) {
if(authentication == null) {
return ACCESS_DENIED;
}
int result = ACCESS_ABSTAIN;
Collection<? extends GrantedAuthority> authorities = extractAuthorities(authentication);
for (ConfigAttribute attribute : attributes) {
if(attribute.getAttribute()==null){
continue;
}
if (this.supports(attribute)) {
result = ACCESS_DENIED;
// Attempt to find a matching granted authority
for (GrantedAuthority authority : authorities) {
if (attribute.getAttribute().equals(authority.getAuthority())) {
return ACCESS_GRANTED;
}
}
}
}
return result;
}
Collection<? extends GrantedAuthority> extractAuthorities(
Authentication authentication) {
return authentication.getAuthorities();
}
@Override
public boolean supports(Class clazz) {
return true;
}
}
如何加入動(dòng)態(tài)權(quán)限呢瓦糟?
vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes)
里的Object object
的類型是FilterInvocation
筒愚,可以通過getRequestUrl
獲取當(dāng)前請求的URL:
FilterInvocation fi = (FilterInvocation) object;
String url = fi.getRequestUrl();
因此這里擴(kuò)展空間就大了赴蝇,可以從DB動(dòng)態(tài)加載菩浙,然后判斷URL的ConfigAttribute就可以了。
如何使用這個(gè)RoleBasedVoter呢句伶?在configure里使用accessDecisionManager方法自定義,我們還是使用官方的UnanimousBased
,然后將自定義的RoleBasedVoter加入即可炭庙。
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(problemSupport)
.accessDeniedHandler(problemSupport)
.and()
.csrf()
.disable()
.headers()
.frameOptions()
.disable()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 自定義accessDecisionManager
.accessDecisionManager(accessDecisionManager())
.and()
.apply(securityConfigurerAdapter());
}
@Bean
public AccessDecisionManager accessDecisionManager() {
List<AccessDecisionVoter<? extends Object>> decisionVoters
= Arrays.asList(
new WebExpressionVoter(),
// new RoleVoter(),
new RoleBasedVoter(),
new AuthenticatedVoter());
return new UnanimousBased(decisionVoters);
}
自定義SecurityMetadataSource
自定義FilterInvocationSecurityMetadataSource只要實(shí)現(xiàn)接口即可厢岂,在接口里從DB動(dòng)態(tài)加載規(guī)則。
為了復(fù)用代碼里的定義楚堤,我們可以將代碼里生成的SecurityMetadataSource帶上疫蔓,在構(gòu)造函數(shù)里傳入默認(rèn)的FilterInvocationSecurityMetadataSource含懊。
public class AppFilterInvocationSecurityMetadataSource implements org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource {
private FilterInvocationSecurityMetadataSource superMetadataSource;
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
public AppFilterInvocationSecurityMetadataSource(FilterInvocationSecurityMetadataSource expressionBasedFilterInvocationSecurityMetadataSource){
this.superMetadataSource = expressionBasedFilterInvocationSecurityMetadataSource;
// TODO 從數(shù)據(jù)庫加載權(quán)限配置
}
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
// 這里的需要從DB加載
private final Map<String,String> urlRoleMap = new HashMap<String,String>(){{
put("/open/**","ROLE_ANONYMOUS");
put("/health","ROLE_ANONYMOUS");
put("/restart","ROLE_ADMIN");
put("/demo","ROLE_USER");
}};
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
FilterInvocation fi = (FilterInvocation) object;
String url = fi.getRequestUrl();
for(Map.Entry<String,String> entry:urlRoleMap.entrySet()){
if(antPathMatcher.match(entry.getKey(),url)){
return SecurityConfig.createList(entry.getValue());
}
}
// 返回代碼定義的默認(rèn)配置
return superMetadataSource.getAttributes(object);
}
@Override
public boolean supports(Class<?> clazz) {
return FilterInvocation.class.isAssignableFrom(clazz);
}
}
怎么使用?和accessDecisionManager
不一樣衅胀,ExpressionUrlAuthorizationConfigurer
并沒有提供set方法設(shè)置FilterSecurityInterceptor
的FilterInvocationSecurityMetadataSource
岔乔,how to do?
發(fā)現(xiàn)一個(gè)擴(kuò)展方法withObjectPostProcessor
,通過該方法自定義一個(gè)處理FilterSecurityInterceptor
類型的ObjectPostProcessor
就可以修改FilterSecurityInterceptor
滚躯。
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(problemSupport)
.accessDeniedHandler(problemSupport)
.and()
.csrf()
.disable()
.headers()
.frameOptions()
.disable()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 自定義FilterInvocationSecurityMetadataSource
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(
O fsi) {
fsi.setSecurityMetadataSource(mySecurityMetadataSource(fsi.getSecurityMetadataSource()));
return fsi;
}
})
.and()
.apply(securityConfigurerAdapter());
}
@Bean
public AppFilterInvocationSecurityMetadataSource mySecurityMetadataSource(FilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource) {
AppFilterInvocationSecurityMetadataSource securityMetadataSource = new AppFilterInvocationSecurityMetadataSource(filterInvocationSecurityMetadataSource);
return securityMetadataSource;
}
小結(jié)
本文介紹了兩種基于spring security實(shí)現(xiàn)動(dòng)態(tài)權(quán)限的方法雏门,一是自定義accessDecisionManager,二是自定義FilterInvocationSecurityMetadataSource掸掏。實(shí)際項(xiàng)目里可以根據(jù)需要靈活選擇茁影。
基于spring security 實(shí)現(xiàn)前后端分離項(xiàng)目權(quán)限控制
前后端分離的項(xiàng)目,前端有菜單(menu)丧凤,后端有API(backendApi)募闲,一個(gè)menu對應(yīng)的頁面有N個(gè)API接口來支持,本文介紹如何基于spring security實(shí)現(xiàn)前后端的同步權(quán)限控制息裸。
實(shí)現(xiàn)思路
還是基于Role來實(shí)現(xiàn)蝇更,具體的思路是,一個(gè)Role擁有多個(gè)Menu呼盆,一個(gè)menu有多個(gè)backendApi年扩,其中Role和menu,以及menu和backendApi都是ManyToMany關(guān)系访圃。
驗(yàn)證授權(quán)也很簡單厨幻,用戶登陸系統(tǒng)時(shí),獲取Role關(guān)聯(lián)的Menu腿时,頁面訪問后端API時(shí)况脆,再驗(yàn)證下用戶是否有訪問API的權(quán)限。
domain定義
我們用JPA來實(shí)現(xiàn)批糟,先來定義Role
public class Role implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 名稱
*/
@NotNull
@ApiModelProperty(value = "名稱", required = true)
@Column(name = "name", nullable = false)
private String name;
/**
* 備注
*/
@ApiModelProperty(value = "備注")
@Column(name = "remark")
private String remark;
@JsonIgnore
@ManyToMany
@JoinTable(
name = "role_menus",
joinColumns = {@JoinColumn(name = "role_id", referencedColumnName = "id")},
inverseJoinColumns = {@JoinColumn(name = "menu_id", referencedColumnName = "id")})
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
@BatchSize(size = 100)
private Set<Menu> menus = new HashSet<>();
}
以及Menu:
public class Menu implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "parent_id")
private Integer parentId;
/**
* 文本
*/
@ApiModelProperty(value = "文本")
@Column(name = "text")
private String text;
@ApiModelProperty(value = "angular路由")
@Column(name = "link")
private String link;
@ManyToMany
@JsonIgnore
@JoinTable(name = "backend_api_menus",
joinColumns = @JoinColumn(name="menus_id", referencedColumnName="id"),
inverseJoinColumns = @JoinColumn(name="backend_apis_id", referencedColumnName="id"))
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
private Set<BackendApi> backendApis = new HashSet<>();
@ManyToMany(mappedBy = "menus")
@JsonIgnore
private Set<Role> roles = new HashSet<>();
}
最后是BackendApi格了,區(qū)分method(HTTP請求方法)、tag(哪一個(gè)Controller)和path(API請求路徑):
public class BackendApi implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "tag")
private String tag;
@Column(name = "path")
private String path;
@Column(name = "method")
private String method;
@Column(name = "summary")
private String summary;
@Column(name = "operation_id")
private String operationId;
@ManyToMany(mappedBy = "backendApis")
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
private Set<Menu> menus = new HashSet<>();
}
管理頁面實(shí)現(xiàn)
Menu菜單是業(yè)務(wù)需求確定的徽鼎,因此提供CRUD編輯即可盛末。
BackendAPI,可以通過swagger來獲取否淤。
前端選擇ng-algin悄但,參見Angular 中后臺前端解決方案 - Ng Alain 介紹
通過swagger獲取BackendAPI
獲取swagger api有多種方法,最簡單的就是訪問http接口獲取json石抡,然后解析檐嚣,這很簡單,這里不贅述啰扛,還有一種就是直接調(diào)用相關(guān)API獲取Swagger對象嚎京。
查看官方的web代碼嗡贺,可以看到獲取數(shù)據(jù)大概是這樣的:
String groupName = Optional.fromNullable(swaggerGroup).or(Docket.DEFAULT_GROUP_NAME);
Documentation documentation = documentationCache.documentationByGroup(groupName);
if (documentation == null) {
return new ResponseEntity<Json>(HttpStatus.NOT_FOUND);
}
Swagger swagger = mapper.mapDocumentation(documentation);
UriComponents uriComponents = componentsFrom(servletRequest, swagger.getBasePath());
swagger.basePath(Strings.isNullOrEmpty(uriComponents.getPath()) ? "/" : uriComponents.getPath());
if (isNullOrEmpty(swagger.getHost())) {
swagger.host(hostName(uriComponents));
}
return new ResponseEntity<Json>(jsonSerializer.toJson(swagger), HttpStatus.OK);
其中的documentationCache、environment鞍帝、mapper等可以直接Autowired獲得:
@Autowired
public SwaggerResource(
Environment environment,
DocumentationCache documentationCache,
ServiceModelToSwagger2Mapper mapper,
BackendApiRepository backendApiRepository,
JsonSerializer jsonSerializer) {
this.hostNameOverride = environment.getProperty("springfox.documentation.swagger.v2.host", "DEFAULT");
this.documentationCache = documentationCache;
this.mapper = mapper;
this.jsonSerializer = jsonSerializer;
this.backendApiRepository = backendApiRepository;
}
然后我們自動(dòng)加載就簡單了暑刃,寫一個(gè)updateApi接口,讀取swagger對象膜眠,然后解析成BackendAPI岩臣,存儲(chǔ)到數(shù)據(jù)庫:
@RequestMapping(
value = "/api/updateApi",
method = RequestMethod.GET,
produces = { APPLICATION_JSON_VALUE, HAL_MEDIA_TYPE })
@PropertySourcedMapping(
value = "${springfox.documentation.swagger.v2.path}",
propertyKey = "springfox.documentation.swagger.v2.path")
@ResponseBody
public ResponseEntity<Json> updateApi(
@RequestParam(value = "group", required = false) String swaggerGroup) {
// 加載已有的api
Map<String,Boolean> apiMap = Maps.newHashMap();
List<BackendApi> apis = backendApiRepository.findAll();
apis.stream().forEach(api->apiMap.put(api.getPath()+api.getMethod(),true));
// 獲取swagger
String groupName = Optional.fromNullable(swaggerGroup).or(Docket.DEFAULT_GROUP_NAME);
Documentation documentation = documentationCache.documentationByGroup(groupName);
if (documentation == null) {
return new ResponseEntity<Json>(HttpStatus.NOT_FOUND);
}
Swagger swagger = mapper.mapDocumentation(documentation);
// 加載到數(shù)據(jù)庫
for(Map.Entry<String, Path> item : swagger.getPaths().entrySet()){
String path = item.getKey();
Path pathInfo = item.getValue();
createApiIfNeeded(apiMap, path, pathInfo.getGet(), HttpMethod.GET.name());
createApiIfNeeded(apiMap, path, pathInfo.getPost(), HttpMethod.POST.name());
createApiIfNeeded(apiMap, path, pathInfo.getDelete(), HttpMethod.DELETE.name());
createApiIfNeeded(apiMap, path, pathInfo.getPut(), HttpMethod.PUT.name());
}
return new ResponseEntity<Json>(HttpStatus.OK);
}
其中createApiIfNeeded,先判斷下是否存在宵膨,不存在的則新增:
private void createApiIfNeeded(Map<String, Boolean> apiMap, String path, Operation operation, String method) {
if(operation==null) {
return;
}
if(!apiMap.containsKey(path+ method)){
apiMap.put(path+ method,true);
BackendApi api = new BackendApi();
api.setMethod( method);
api.setOperationId(operation.getOperationId());
api.setPath(path);
api.setTag(operation.getTags().get(0));
api.setSummary(operation.getSummary());
// 保存
this.backendApiRepository.save(api);
}
}
最后架谎,做一個(gè)簡單頁面展示即可:
菜單管理
新增和修改頁面,可以選擇上級菜單辟躏,后臺API做成按tag分組谷扣,可多選即可:
列表頁面
角色管理
普通的CRUD,最主要的增加一個(gè)菜單授權(quán)頁面捎琐,菜單按層級顯示即可:
認(rèn)證實(shí)現(xiàn)
管理頁面可以做成千奇百樣会涎,最核心的還是如何實(shí)現(xiàn)認(rèn)證。
在上一篇文章spring security實(shí)現(xiàn)動(dòng)態(tài)配置url權(quán)限的兩種方法里我們說了瑞凑,可以自定義FilterInvocationSecurityMetadataSource
來實(shí)現(xiàn)末秃。
實(shí)現(xiàn)FilterInvocationSecurityMetadataSource
接口即可,核心是根據(jù)FilterInvocation的Request的method和path籽御,獲取對應(yīng)的Role练慕,然后交給RoleVoter去判斷是否有權(quán)限。
自定義FilterInvocationSecurityMetadataSource
我們新建一個(gè)DaoSecurityMetadataSource實(shí)現(xiàn)FilterInvocationSecurityMetadataSource接口技掏,主要看getAttributes方法:
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
FilterInvocation fi = (FilterInvocation) object;
List<Role> neededRoles = this.getRequestNeededRoles(fi.getRequest().getMethod(), fi.getRequestUrl());
if (neededRoles != null) {
return SecurityConfig.createList(neededRoles.stream().map(role -> role.getName()).collect(Collectors.toList()).toArray(new String[]{}));
}
// 返回默認(rèn)配置
return superMetadataSource.getAttributes(object);
}
核心是getRequestNeededRoles怎么實(shí)現(xiàn)铃将,獲取到干凈的RequestUrl(去掉參數(shù)),然后看是否有對應(yīng)的backendAPI,如果沒有哑梳,則有可能該API有path參數(shù)劲阎,我們可以去掉最后的path,去庫里模糊匹配鸠真,直到找到悯仙。
public List<Role> getRequestNeededRoles(String method, String path) {
String rawPath = path;
// remove parameters
if(path.indexOf("?")>-1){
path = path.substring(0,path.indexOf("?"));
}
// /menus/{id}
BackendApi api = backendApiRepository.findByPathAndMethod(path, method);
if (api == null){
// try fetch by remove last path
api = loadFromSimilarApi(method, path, rawPath);
}
if (api != null && api.getMenus().size() > 0) {
return api.getMenus()
.stream()
.flatMap(menu -> menuRepository.findOneWithRolesById(menu.getId()).getRoles().stream())
.collect(Collectors.toList());
}
return null;
}
private BackendApi loadFromSimilarApi(String method, String path, String rawPath) {
if(path.lastIndexOf("/")>-1){
path = path.substring(0,path.lastIndexOf("/"));
List<BackendApi> apis = backendApiRepository.findByPathStartsWithAndMethod(path, method);
// 如果為空,再去掉一層path
while(apis==null){
if(path.lastIndexOf("/")>-1) {
path = path.substring(0, path.lastIndexOf("/"));
apis = backendApiRepository.findByPathStartsWithAndMethod(path, method);
}else{
break;
}
}
if(apis!=null){
for(BackendApi backendApi : apis){
if (antPathMatcher.match(backendApi.getPath(), rawPath)) {
return backendApi;
}
}
}
}
return null;
}
其中弧哎,BackendApiRepository:
@EntityGraph(attributePaths = "menus")
BackendApi findByPathAndMethod(String path,String method);
@EntityGraph(attributePaths = "menus")
List<BackendApi> findByPathStartsWithAndMethod(String path,String method);
以及MenuRepository
@EntityGraph(attributePaths = "roles")
Menu findOneWithRolesById(long id);
使用DaoSecurityMetadataSource
需要注意的是雁比,在DaoSecurityMetadataSource里稚虎,不能直接注入Repository撤嫩,我們可以給DaoSecurityMetadataSource添加一個(gè)方法,方便傳入:
public void init(MenuRepository menuRepository, BackendApiRepository backendApiRepository) {
this.menuRepository = menuRepository;
this.backendApiRepository = backendApiRepository;
}
然后建立一個(gè)容器蠢终,存儲(chǔ)實(shí)例化的DaoSecurityMetadataSource序攘,我們可以建立如下的ApplicationContext來作為對象容器茴她,存取對象:
public class ApplicationContext {
static Map<Class<?>,Object> beanMap = Maps.newConcurrentMap();
public static <T> T getBean(Class<T> requireType){
return (T) beanMap.get(requireType);
}
public static void registerBean(Object item){
beanMap.put(item.getClass(),item);
}
}
在SecurityConfiguration配置中使用DaoSecurityMetadataSource
,并通過ApplicationContext.registerBean
將DaoSecurityMetadataSource
注冊:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(problemSupport)
.accessDeniedHandler(problemSupport)
....
// .withObjectPostProcessor()
// 自定義accessDecisionManager
.accessDecisionManager(accessDecisionManager())
// 自定義FilterInvocationSecurityMetadataSource
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(
O fsi) {
fsi.setSecurityMetadataSource(daoSecurityMetadataSource(fsi.getSecurityMetadataSource()));
return fsi;
}
})
.and()
.apply(securityConfigurerAdapter());
}
@Bean
public DaoSecurityMetadataSource daoSecurityMetadataSource(FilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource) {
DaoSecurityMetadataSource securityMetadataSource = new DaoSecurityMetadataSource(filterInvocationSecurityMetadataSource);
ApplicationContext.registerBean(securityMetadataSource);
return securityMetadataSource;
}
最后程奠,在程序啟動(dòng)后丈牢,通過ApplicationContext.getBean
獲取到daoSecurityMetadataSource,然后調(diào)用init注入Repository
public static void postInit(){
ApplicationContext
.getBean(DaoSecurityMetadataSource.class)
.init(applicationContext.getBean(MenuRepository.class),applicationContext.getBean(BackendApiRepository.class));
}
static ConfigurableApplicationContext applicationContext;
public static void main(String[] args) throws UnknownHostException {
SpringApplication app = new SpringApplication(UserCenterApp.class);
DefaultProfileUtil.addDefaultProfile(app);
applicationContext = app.run(args);
// 后初始化
postInit();
}
大功告成瞄沙!