Spring Security and Angular 實現用戶認證

引言

度過了前端框架的技術選型以後,新系統起步。html

ng-alain,一款功能強大的前端框架,設計得很好,兩大缺點,文檔不詳盡,框架代碼不規範。前端

寫前臺攔截器的時候是在花了大約半小時的時間對代碼進行全面規範化以後纔開始進行的。java

clipboard.png

又回到了最原始的問題,認證受權,也就是Security程序員

認證受權

認證,也就是判斷用戶是否可登陸系統。web

受權,用戶登陸系統後能夠幹什麼,哪些操做被容許。redis

本文,咱們使用Spring SecurityAngular進行用戶認證。spring

clipboard.png

開發環境

  • 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開發者社區上的博客進行學習(最近才發現的,上面寫的都特別好,有的做者怕文字說不明白的還特地錄了個視頻放在上面)。

學習 - 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保護起來了,當訪問接口時,瀏覽器會彈出登陸提示框。

clipboard.png

用戶名是user,密碼已打印在控制檯:

clipboard.png

自定義認證

這不行呀,不可能項目一上線,用的仍是隨機生成的用戶名和密碼,應該去數據庫裏查。

實現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?

其實,若是對技術要求不嚴謹的人來講,上面已經足夠了。若是你也有一顆崇尚技術的心,咱們一塊兒往下看。

嘿!個人應用程序怎麼擴大規模?

clipboard.png

這是Spring官方文檔中引出的話題,官方文檔中對這一塊的描述過於學術,什麼TCP,什麼stateless

說實話,這段我看了好幾遍也沒看懂,可是我很是贊成這個結論:咱們不能用Spring Security幫咱們管理Session

如下是我我的的觀點:由於這是存在本地的,當咱們的後臺有好多臺服務器,怎麼辦?用戶此次請求的是Server1Server1上存了一個seesion,而後下次請求的是Server2Server2沒有session,完了,401

因此咱們要禁用Spring SecuritySession,可是手動管理Session又太複雜,因此引入了新項目:Spring Session

Spring Session的一大優勢也是支持集羣Session

clipboard.png

引入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 SecuritySession管理

@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 SecuritySession管理,設置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啓用RedisSession管理,上下文中加入對象HeaderHttpSessionIdResolver,設置從Http請求中找header裏的token最爲認證字段。

梳理邏輯

很亂是嗎?讓咱們從新梳理一下邏輯。

clipboard.png

使用HttpBasic方式登陸,用戶名和密碼傳給後臺,Spring Security進行用戶認證,而後根據咱們的配置,Spring Security使用的是Spring Session建立的Session,最後存入Redis

之後呢?

登陸以後,就是用token的方式進行用戶認證,將token添加到header中,而後請求的時候後臺識別header裏的token進行用戶認證。

clipboard.png

因此,咱們須要在用戶登陸的時候返回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());
}

簡簡單單的四行,就實現了後臺的用戶認證。

原理

clipboard.png

由於咱們的後臺是受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();
    }
}

總結

一款又一款框架,是前輩們智慧的結晶。

永遠,文檔比書籍更珍貴!
相關文章
相關標籤/搜索