度過了前端框架的技術選型以後,新系統起步。html
ng-alain
,一款功能強大的前端框架,設計得很好,兩大缺點,文檔不詳盡,框架代碼不規範。前端
寫前臺攔截器的時候是在花了大約半小時的時間對代碼進行全面規範化以後纔開始進行的。java
又回到了最原始的問題,認證受權,也就是Security
。程序員
認證,也就是判斷用戶是否可登陸系統。web
受權,用戶登陸系統後能夠幹什麼,哪些操做被容許。redis
本文,咱們使用Spring Security
與Angular
進行用戶認證。spring
Java 1.8
Spring Boot 2.0.5.RELEASE
這裏給你們介紹一下我學習用戶認證的經歷。typescript
第一步,確定是想去看官方文檔,Spring Security and Angular - Spring.io。數據庫
感嘆一句這個文檔,實在是太長了!!!json
記得當時看這個文檔看了一夜,看完還不敢睡覺,一氣呵成寫完,就怕次日起來把學得都忘了。
我看完這個文檔,其實咱們須要的並非文檔的所有。總結一下文檔的結構:
basic
方式登陸form
方式登陸,並自定義登陸表單CSRF
保護(這塊沒看懂,好像就是防止僞造而後多存一個X-XSRF-TOKEN
)API
網關進行轉發(計量項目原實現方式)Spring Session
自定義token
Oauth2
登陸文檔寫的很好,講解了許多why?
,咱們爲何要這麼設計。
我猜測這篇文章應該默認學者已經掌握Spring Security
,反正我零基礎看着挺費勁的。初學建議結合IBM
開發者社區上的博客進行學習(最近才發現的,上面寫的都特別好,有的做者怕文字說不明白的還特地錄了個視頻放在上面)。
這是我結合學習的文章:Spring Security 的 Web 應用和指紋登陸實踐
Security
依賴<!-- Security --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
繼承配置適配器WebSecurityConfigurerAdapter
,就實現了Spring Security
的配置。
重寫configure
,自定義認證規則。
注意,configure
裏的代碼不要當成代碼看,不然會死得很慘。就把他當成普通的句子看!!!
@Component public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http // 使用Basic認證方式進行驗證進行驗證 .httpBasic() // 要求SpringSecurity對後臺的任何請求進行認證保護 .and().authorizeRequests().anyRequest().authenticated(); } }
如此,咱們後臺的接口就被Spring Security
保護起來了,當訪問接口時,瀏覽器會彈出登陸提示框。
用戶名是user
,密碼已打印在控制檯:
這不行呀,不可能項目一上線,用的仍是隨機生成的用戶名和密碼,應該去數據庫裏查。
實現UserDetailsService
接口並交給Spring
託管,在用戶認證時,Spring Security
即自動調用咱們實現的loadUserByUsername
方法,傳入username
,而後再用咱們返回的對象進行其餘認證操做。
該方法要求咱們根據咱們本身的User
來構造Spring Security
內置的org.springframework.security.core.userdetails.User
,若是拋出UsernameNotFoundException
,則Spring Security
代替咱們返回401
。
@Component public class YunzhiAuthService implements UserDetailsService { @Autowired private UserRepository userRepository; private static final Logger logger = LoggerFactory.getLogger(YunzhiAuthService.class); @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { logger.debug("根據用戶名查詢用戶"); User user = userRepository.findUserByUsername(username); logger.debug("用戶爲空,則拋出異常"); if (user == null) { throw new UsernameNotFoundException("用戶名不存在"); } // TODO: 學習Spring Security中的role受權,看是否對項目有所幫助 return new org.springframework.security.core.userdetails.User(username, user.getPassword(), AuthorityUtils.createAuthorityList("admin")); } }
基礎的代碼你們都能看懂,這裏講解一下最後一句。
return new org.springframework.security.core.userdetails.User(username, user.getPassword(), AuthorityUtils.createAuthorityList("admin"));
構建一個用戶,用戶名密碼都是咱們查出來set
進去的,對該用戶受權admin
角色(暫且這麼寫,這個對用戶授予什麼角色關係到受權,咱們往後討論)。
而後Spring Security
就調用咱們返回的User
對象進行密碼判斷與用戶受權。
用戶凍結
Spring Security
只有用戶名和密碼認證嗎?那用戶凍結了怎麼辦呢?
這個無須擔憂,點開org.springframework.security.core.userdetails.User
,一個三個參數的構造函數,一個七個參數的構造函數,去看看源碼中的註釋,一切都不是問題。Spring Security
設計得至關完善。
public User(String username, String password, Collection<? extends GrantedAuthority> authorities) { this(username, password, true, true, true, true, authorities); } public User(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) { if (((username == null) || "".equals(username)) || (password == null)) { throw new IllegalArgumentException( "Cannot pass null or empty values to constructor"); } this.username = username; this.password = password; this.enabled = enabled; this.accountNonExpired = accountNonExpired; this.credentialsNonExpired = credentialsNonExpired; this.accountNonLocked = accountNonLocked; this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities)); }
忘了當時是什麼場景了,好像是寫完YunzhiAuthService
以後再啓動項目,控制檯中就有提示:具體內容記不清了,大致意思就是推薦我採用密碼加密。
特地查了一下數據庫中的密碼需不須要加密,而後就查到了CSDN
的密碼泄露事件,不少開發者都批判CSDN
的程序員,說明文存儲密碼是一種很是不服責任的行爲。
而後又搜到了騰訊有關的一些文章,反正密碼加密了,數據泄露了也不用承擔過多的法律責任。騰訊仍是走在法律的前列啊,話說是否是騰訊打官司還沒輸過?
既然這麼多人都推薦加密,那咱們也用一用吧。去Google
了一下查了,好像BCryptPasswordEncoder
挺經常使用的,就添加到上下文裏了,而後Spring Security
再進行密碼判斷的話,就會把傳來的密碼通過BCryptPasswordEncoder
加密,判斷和咱們傳給它的加密密碼是否一致。
@Configuration public class BeanConfig { /** * 密碼加密 */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
而後一些User
的細節就參考李宜衡的文章:Hibernate實體監聽器。
Help, How is My Application Going to Scale?
其實,若是對技術要求不嚴謹的人來講,上面已經足夠了。若是你也有一顆崇尚技術的心,咱們一塊兒往下看。
嘿!個人應用程序怎麼擴大規模?
這是Spring
官方文檔中引出的話題,官方文檔中對這一塊的描述過於學術,什麼TCP
,什麼stateless
。
說實話,這段我看了好幾遍也沒看懂,可是我很是贊成這個結論:咱們不能用Spring Security
幫咱們管理Session
。
如下是我我的的觀點:由於這是存在本地的,當咱們的後臺有好多臺服務器,怎麼辦?用戶此次請求的是Server1
,Server1
上存了一個seesion
,而後下次請求的是Server2
,Server2
沒有session
,完了,401
。
因此咱們要禁用Spring Security
的Session
,可是手動管理Session
又太複雜,因此引入了新項目:Spring Session
。
Spring Session
的一大優勢也是支持集羣Session
。
Spring Session
<!-- Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- Session --> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>
這裏引入的是Spring Session
中的Session-Redis
項目,使用Redis
服務器存儲Session
,實現集羣共享。
Spring Security
的Session
管理@Component public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http // 使用Basic認證方式進行驗證進行驗證 .httpBasic() // 要求SpringSecurity對後臺的任何請求進行認證保護 .and().authorizeRequests().anyRequest().authenticated() // 關閉Security的Session,使用Spring Session .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER); } }
關閉Spring Security
的Session
管理,設置Session
建立策略爲NEVER
。
Spring Security will never create an HttpSession, but will use the HttpSession if it already exists
Spring Security
不會建立HttpSession
,可是若是存在,會使用這個HttpSession
。
Redis
管理Session
Mac
下使用Homebrew
安裝redis
十分簡單,Mac下安裝配置Redis。
@EnableRedisHttpSession @Configuration public class BeanConfig { /** * 設置Session的token策略 */ @Bean public HeaderHttpSessionIdResolver httpSessionIdResolver() { return new HeaderHttpSessionIdResolver("token"); } }
@EnableRedisHttpSession
啓用Redis
的Session
管理,上下文中加入對象HeaderHttpSessionIdResolver
,設置從Http
請求中找header
裏的token
最爲認證字段。
很亂是嗎?讓咱們從新梳理一下邏輯。
使用HttpBasic
方式登陸,用戶名和密碼傳給後臺,Spring Security
進行用戶認證,而後根據咱們的配置,Spring Security
使用的是Spring Session
建立的Session
,最後存入Redis
。
之後呢?
登陸以後,就是用token
的方式進行用戶認證,將token
添加到header
中,而後請求的時候後臺識別header
裏的token
進行用戶認證。
因此,咱們須要在用戶登陸的時候返回token
做爲之後用戶認證的條件。
登陸方案,參考官方文檔學來的,很巧妙。
以Spring
的話來講:這個叫trick
,小騙術。
咱們的login
方法長成這樣:
@GetMapping("login") public Map<String, String> login(@AuthenticationPrincipal Principal user, HttpSession session) { logger.info("用戶: " + user.getName() + "登陸系統"); return Collections.singletonMap("token", session.getId()); }
簡簡單單的四行,就實現了後臺的用戶認證。
原理
由於咱們的後臺是受Spring Security
保護的,因此當訪問login
方法時,就須要進行用戶認證,認證成功才能執行到login
方法。
換句話說,只要咱們的login
方法執行到了,那就說明用戶認證成功,因此login
方法徹底不須要業務邏輯,直接返回token
,供以後認證使用。
怎麼樣,是否是很巧妙?
註銷至關簡單,直接清空當前的用戶認證信息便可。
@GetMapping("logout") public void logout(HttpServletRequest request, HttpServletResponse response) { logger.info("用戶註銷"); // 獲取用戶認證信息 Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // 存在認證信息,註銷 if (authentication != null) { new SecurityContextLogoutHandler().logout(request, response, authentication); } }
若是對整個流程不是很明白的話,看下面的單元測試會有所幫助,代碼很詳盡,請理解整個認證的流程。
@RunWith(SpringRunner.class) @SpringBootTest @AutoConfigureMockMvc @Transactional public class AuthControllerTest { private static final Logger logger = LoggerFactory.getLogger(AuthControllerTest.class); private static final String LOGIN_URL = "/auth/login"; private static final String LOGOUT_URL = "/auth/logout"; private static final String TOKEN_KEY = "token"; @Autowired private MockMvc mockMvc; @Test public void securityTest() throws Exception { logger.debug("初始化基礎變量"); String username; String password; byte[] encodedBytes; MvcResult mvcResult; logger.debug("1. 測試用戶名不存在"); username = CommonService.getRandomStringByLength(10); password = "admin"; encodedBytes = Base64.encodeBase64((username + ":" + password).getBytes()); logger.debug("斷言401"); this.mockMvc.perform(MockMvcRequestBuilders.get(LOGIN_URL) .header("Authorization", "Basic " + new String(encodedBytes))) .andExpect(status().isUnauthorized()); logger.debug("2. 用戶名存在,但密碼錯誤"); username = "admin"; password = CommonService.getRandomStringByLength(10); encodedBytes = Base64.encodeBase64((username + ":" + password).getBytes()); logger.debug("斷言401"); this.mockMvc.perform(MockMvcRequestBuilders.get(LOGIN_URL) .header("Authorization", "Basic " + new String(encodedBytes))) .andExpect(status().isUnauthorized()); logger.debug("3. 用戶名密碼正確"); username = "admin"; password = "admin"; encodedBytes = Base64.encodeBase64((username + ":" + password).getBytes()); logger.debug("斷言200"); mvcResult = this.mockMvc.perform(MockMvcRequestBuilders.get(LOGIN_URL) .header("Authorization", "Basic " + new String(encodedBytes))) .andExpect(status().isOk()) .andReturn(); logger.debug("從返回體中獲取token"); String json = mvcResult.getResponse().getContentAsString(); JSONObject jsonObject = JSON.parseObject(json); String token = jsonObject.getString("token"); logger.debug("空的token請求後臺,斷言401"); this.mockMvc.perform(MockMvcRequestBuilders.get(LOGIN_URL) .header(TOKEN_KEY, "")) .andExpect(status().isUnauthorized()); logger.debug("加上token請求後臺,斷言200"); this.mockMvc.perform(MockMvcRequestBuilders.get(LOGIN_URL) .header(TOKEN_KEY, token)) .andExpect(status().isOk()); logger.debug("用戶註銷"); this.mockMvc.perform(MockMvcRequestBuilders.get(LOGOUT_URL) .header(TOKEN_KEY, token)) .andExpect(status().isOk()); logger.debug("註銷後,斷言該token失效"); this.mockMvc.perform(MockMvcRequestBuilders.get(LOGIN_URL) .header(TOKEN_KEY, token)) .andExpect(status().isUnauthorized()); } }
和這麼複雜的後臺設計相比較,前臺沒有啥技術含量,把代碼粘貼出來你們參考參考便可,沒什麼要說的。
前臺Service
:
@Injectable({ providedIn: 'root', }) export class AuthService { constructor(private http: _HttpClient) { } /** * 登陸 * @param username 用戶名 * @param password 密碼 */ public login(username: string, password: string): Observable<ITokenModel> { // 新建Headers,並添加認證信息 let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); headers = headers.append('Authorization', 'Basic ' + btoa(username + ':' + password)); // 發起get請求並返回 return this.http .get('/auth/login?_allow_anonymous=true', {}, { headers: headers }); } /** * 註銷 */ public logout(): Observable<any> { return this.http.get('/auth/logout'); } }
登陸組件核心代碼:
this.authService.login(this.userName.value, this.password.value) .subscribe((response: ITokenModel) => { // 清空路由複用信息 this.reuseTabService.clear(); // 設置用戶Token信息 this.tokenService.set(response); // 從新獲取 StartupService 內容,咱們始終認爲應用信息通常都會受當前用戶受權範圍而影響 this.startupSrv.load().then(() => { // noinspection JSIgnoredPromiseFromCall this.router.navigateByUrl('main/index'); }); }, () => { // 顯示錯誤信息提示 this.showLoginErrorInfo = true; });
註銷組件核心代碼:
// 調用Service進行註銷 this.authService.logout().subscribe(() => { }, () => { }, () => { // 清空token信息 this.tokenService.clear(); // 跳轉到登陸頁面,由於不管是否註銷成功都要跳轉,寫在complete中 // noinspection JSIgnoredPromiseFromCall this.router.navigateByUrl(this.tokenService.login_url); });
有一點,headers.append('X-Requested-With', 'XMLHttpRequest')
,若是不設置這個,在用戶名密碼錯誤的時候會彈出Spring Security
原生的登陸提示框。
還有就是,爲何這裏沒有處理token
,由於Ng-Alain
的默認的攔截器已經對token
進行添加處理。
// noinspection SpellCheckingInspection /** * Yunzhi攔截器,用於實現添加url,添加header,全局異常處理 */ @Injectable() export class YunzhiInterceptor implements HttpInterceptor { constructor(private router: Router) { } intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { /** * 爲request加上服務端前綴 */ let url = req.url; if (!url.startsWith('https://') && !url.startsWith('http://')) { url = environment.SERVER_URL + url; } let request = req.clone({ url }); /** * 設置headers,防止彈出對話框 * https://stackoverflow.com/questions/37763186/spring-boot-security-shows-http-basic-auth-popup-after-failed-login */ let headers = request.headers; headers = headers.append('X-Requested-With', 'XMLHttpRequest'); request = request.clone({ headers: headers }); /** * 數據過濾 */ return next.handle(request).pipe( // mergeMap = merge + map mergeMap((event: any) => { return of(event); }), // Observable對象發生錯誤時,執行catchError catchError((error: HttpErrorResponse) => { return this.handleHttpException(error); }), ); } private handleHttpException(error: HttpErrorResponse): Observable<HttpErrorResponse> { switch (error.status) { case 401: if (this.router.url !== '/passport/login') { // noinspection JSIgnoredPromiseFromCall this.router.navigateByUrl('/passport/login'); } break; case 403: case 404: case 500: // noinspection JSIgnoredPromiseFromCall this.router.navigateByUrl(`/${error.status}`); break; } // 最終將異常拋出來,便於組件個性化處理 throw new Error(error.error); } }
H2
控制檯看不見問題Spring Security
直接把H2
數據庫的控制檯也攔截了,且禁止查看,啓用如下配置恢復控制檯查看。
@Component public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http // 使用Basic認證方式進行驗證進行驗證 .httpBasic() // 要求SpringSecurity對後臺的任何請求進行認證保護 .and().authorizeRequests().anyRequest().authenticated() // 關閉Security的Session,使用Spring Session .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER) // 設置frameOptions爲sameOrigin,不然看不見h2控制檯 .and().headers().frameOptions().sameOrigin() // 禁用csrf,不然403. 這個在上線的時候判斷是否須要開啓 .and().csrf().disable(); } }
一款又一款框架,是前輩們智慧的結晶。
永遠,文檔比書籍更珍貴!