二凉敲、Spring Security登錄化撕、Session及退出配置

一儡毕、登錄配置

對于表單登錄簇捍,能配置登錄成功和失敗的跳轉(zhuǎn)和重定向,Spring Security通過配置可以實(shí)現(xiàn)自定義跳轉(zhuǎn)杜漠、重定向极景,以及用戶未登錄和登錄用戶無權(quán)限的處理。

1.1驾茴、URL配置

1.1.1盼樟、添加依賴

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

1.1.2、自定義登錄頁面

resources/templates下編寫簡單test-login.html登錄頁面(參考官方文檔)锈至,內(nèi)容如下:

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
    <meta charset="UTF-8">
    <title>登錄頁面</title>
</head>
<body>
    <div th:if="${param.error}">
        <p>用戶名或密碼無效</p>
    </div>
    <form th:action="@{/my-login}" method="post">
        <div><label> 用戶名 : <input type="text" name="username"/> </label></div>
        <div><label> 密碼: <input type="password" name="password"/> </label></div>
        <button type="submit" class="btn">登錄</button>
    </form>
</body>
</html>

用戶名和密碼名稱默認(rèn)是usernamepassword

創(chuàng)建登錄頁面映射Controller

@Controller  // 這里使用@Controller,跳轉(zhuǎn)動(dòng)態(tài)頁面
public class PageController {
    @GetMapping("/user-login")
    public String myLoginPage(){
        return "test-login.html";
    }
}

1.1.3晨缴、WebSecurityConfig配置

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                    // 自定義頁面的路徑不用驗(yàn)證
                    .antMatchers(HttpMethod.GET, "/user-login").permitAll()
                    .anyRequest().authenticated() 
                .and()
                    .formLogin()
                    // 設(shè)置自定義登錄的頁面
                    .loginPage("/user-login")
                    // 登錄頁表單提交的 action(th:action="@{/my-login}") URL
                    .loginProcessingUrl("/my-login");   
                    // post請求默認(rèn)需要csrf驗(yàn)證, 這里使用Thymeleaf模板引擎,表單默認(rèn)發(fā)送csrf峡捡,可不用關(guān)閉
                    //.and()
                    //.csrf().disable(); 
    }
}

啟動(dòng)程序后喜庞,訪問localhost:8080/hello,會(huì)跳轉(zhuǎn)到自定義登錄頁面登錄成功棋返,在F12可以看到自動(dòng)發(fā)送csrf

圖片

其他的登錄成功和登錄失敗參考上面,配置如下:

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                    // 自定義頁面的路徑不用驗(yàn)證
                    .antMatchers(HttpMethod.GET, "/user-login").permitAll()
                    // 失敗跳轉(zhuǎn)不用驗(yàn)證
                    .antMatchers(HttpMethod.GET, "/user-fail").permitAll()
                    .anyRequest().authenticated() 
                .and()
                    .formLogin()
                    // 設(shè)置自定義登錄的頁面
                    .loginPage("/user-login")
                    // 登錄頁表單提交的 action(th:action="@{/my-login}") URL
                    .loginProcessingUrl("/my-login");   
                   // .usernameParameter("username") // 默認(rèn)就是 username
                  // .passwordParameter("password") // 默認(rèn)就是 password
                    /** 
                     *  登錄成功跳轉(zhuǎn):
                     *  登錄成功雷猪,如果是直接從登錄頁面登錄睛竣,會(huì)跳轉(zhuǎn)到該URL;
                     *  如果是從其他頁面跳轉(zhuǎn)到登錄頁面求摇,登錄后會(huì)跳轉(zhuǎn)到原來頁面射沟。
                     *  可設(shè)置true來任何時(shí)候到跳轉(zhuǎn) .defaultSuccessUrl("/hello2", true);
                     */
                    .defaultSuccessUrl("/hello2");
                    /**
                     *  登錄成功重定向(和上面二選一)
                     */
                    .successForwardUrl("/hello3")
                    /**
                     *  登錄失敗跳轉(zhuǎn),指定的路徑要能匿名訪問
                     */
                    .failureUrl("/login-fail")
                      /**
                       *  登錄失敗重定向(和上面二選一)
                       */
                    .failureForwardUrl("/login-fail");
                    // post請求需要csrf驗(yàn)證, 這里使用Thymeleaf模板引擎与境,表單默認(rèn)發(fā)送csrf验夯,可不用關(guān)閉
                    //.and()
                    //.csrf().disable(); 
    }
}

1.2、登錄處理器

上面使用URL進(jìn)行的配置摔刁,都是通過Security默認(rèn)提供的處理器處理的挥转,一般多用于前后端不分離。

Spring SecurityAuthenticationManager用來處理身份認(rèn)證的請求,處理的結(jié)果分兩種:

  • 認(rèn)證成功:結(jié)果由AuthenticationSuccessHandler處理
  • 認(rèn)證失敯笠ァ:結(jié)果由AuthenticationFailureHandler處理党窜。

Spring Security提供了多個(gè)實(shí)現(xiàn)于AuthenticationSuccessHandler接口和CustomAuthenticationFailHandler接口的子類,想自定義處理器借宵,可以實(shí)現(xiàn)接口幌衣,或繼承接口的實(shí)現(xiàn)類來重寫。

1.2.1壤玫、自定義AuthenticationSuccessHandler

AuthenticationSuccessHandler是身份驗(yàn)證成功處理器的接口豁护,其下有多個(gè)子類:

  • SavedRequestAwareAuthenticationSuccessHandler:默認(rèn)的成功處理器,默認(rèn)驗(yàn)證成功后欲间,跳轉(zhuǎn)到原路徑楚里。也可通過defaultSuccessUrl()配置。
  • SimpleUrlAuthenticationSuccessHandlerSavedRequestAwareAuthenticationSuccessHandler的父類括改,只有指定defaultSuccessUrl()時(shí)腻豌,才會(huì)被調(diào)用。作用:清除原路徑嘱能,使用defaultSuccessUrl()指定的路徑吝梅。如果直接使用該處理器,則總跳轉(zhuǎn)到根路徑惹骂。
  • ForwardAuthenticationSuccessHandler:請求重定向苏携。只有指定successForwardUrl時(shí)被用到。

要想自定義成功處理器对粪,可以通過實(shí)現(xiàn)AuthenticationSuccessHandler接口或繼承其子類SavedRequestAwareAuthenticationSuccessHandler來實(shí)現(xiàn):

  • 實(shí)現(xiàn)AuthenticationSuccessHandler接口

    如果直接返回Json數(shù)據(jù)時(shí)右冻,可以實(shí)現(xiàn)AuthenticationSuccessHandler接口:

    public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler{
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, 
                                            HttpServletResponse response, 
                                            Authentication authentication) 
            throws ServletException, IOException {
          response.setContentType("application/json;charset=UTF-8");
          response.getWriter().append(
                  new ObjectMapper().createObjectNode()
                          .put("status", 200)
                          .put("msg", "登錄成功")
                          .toString());
        }
    }
    
  • 繼承SavedRequestAwareAuthenticationSuccessHandler

    如果只是在登錄認(rèn)證后,需要處理數(shù)據(jù)著拭,再跳轉(zhuǎn)回原路徑時(shí)纱扭,可以繼承該類:

    public class CustomAuthenticationSuccessHandler2 extends SavedRequestAwareAuthenticationSuccessHandler {
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, 
                                            HttpServletResponse response, 
                                            Authentication authentication) 
            throws ServletException, IOException {
            // 登錄成功后,進(jìn)行數(shù)據(jù)處理
            System.out.println("用戶登錄成功啦@苷凇H槎辍!");
            String authenticationStr = objectMapper.writeValueAsString(authentication);
            System.out.println("用戶登錄信息打颖杀摇:" + authenticationStr);
    
            //處理完成后肃叶,跳轉(zhuǎn)回原請求URL
            super.onAuthenticationSuccess(request, response, authentication);
        }
    }
    

Spring Security默認(rèn)是使用SavedRequestAwareAuthenticationSuccessHandler,在配置中修改為自定義的AuthenticationSuccessHandler

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    ...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                // 配置使用自定義成功處理器
                .successHandler(new AuthenticationSuccessHandler());
    }
}

1.2.2十嘿、自定義AuthenticationFailureHandler

AuthenticationFailureHandler是身份認(rèn)證失敗處理器的接口因惭,其下有多個(gè)子類實(shí)現(xiàn):

  • SimpleUrlAuthenticationFailureHandler:默認(rèn)的失敗處理器,默認(rèn)認(rèn)證失敗后绩衷,跳轉(zhuǎn)到登錄頁路徑加error參數(shù)蹦魔,如:http://localhost:8080/login?error激率。可通過failureUrl()配置版姑。
  • ForwardAuthenticationFailureHandler:重定向到指定的URL
  • DelegatingAuthenticationFailureHandler:將AuthenticationException子類委托給不同的AuthenticationFailureHandler柱搜,意味著可以為AuthenticationException的不同實(shí)例創(chuàng)建不同的行為
  • ExceptionMappingAuthenticationFailureHandler:可以根據(jù)不同的AuthenticationException 類型,設(shè)置不同的跳轉(zhuǎn) url

自定義失敗處理器,可以通過實(shí)現(xiàn)AuthenticationFailureHandler接口或繼承其子類SimpleUrlAuthenticationFailureHandler來實(shí)現(xiàn):

  • 實(shí)現(xiàn)AuthenticationFailureHandler接口:

    public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
        @Override
        public void onAuthenticationFailure(HttpServletRequest request, 
                                            HttpServletResponse response, 
                                            AuthenticationException exception) 
            throws IOException, ServletException {
          response.setContentType("application/json;charset=UTF-8");
          response.getWriter().append(
                  new ObjectMapper().createObjectNode()
                          .put("status", 401)
                          .put("msg", "用戶名或密碼錯(cuò)誤")
                          .toString());
        }
    }
    
  • 繼承SimpleUrlAuthenticationFailureHandler

    public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {  
        @Override
        public void onAuthenticationFailure(HttpServletRequest request, 
                                            HttpServletResponse response, 
                                            AuthenticationException exception) 
            throws IOException, ServletException {
            // 登錄失敗后剥险,進(jìn)行數(shù)據(jù)處理
            System.out.println("登錄失敗啦4险骸!表制!");
            String exceptionStr = objectMapper.writeValueAsString(exception.getMessage());
            System.out.println(exceptionStr);
    
            // 跳轉(zhuǎn)原頁面
            super.onAuthenticationFailure(request, response, exception);
        }
    }
    

Spring Security默認(rèn)驗(yàn)證失敗是使用SimpleUrlAuthenticationFailureHandler健爬,在配置中修改為自定義的AuthenticationFailureHandler

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    ...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                // 配置使用自定義失敗處理器
                .failureHandler(new AuthenticationFailureHandler());
    }
}

這里順便提及DelegatingAuthenticationFailureHandlerExceptionMappingAuthenticationFailureHandler的使用:

  • DelegatingAuthenticationFailureHandler

    @EnableWebSecurity
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
        ...
        @Bean
        public DelegatingAuthenticationFailureHandler delegatingAuthenticationFailureHandler(){
            LinkedHashMap<Class<? extends AuthenticationException>, AuthenticationFailureHandler> handlers = new LinkedHashMap<>();
            // 登錄失敗時(shí),使用的失敗處理器
            handlers.put(BadCredentialsException.class, new BadCredentialsAuthenticationFailureHandler());
            // 用戶過期時(shí)么介,使用的失敗處理器
            handlers.put(AccountExpiredException.class, new AccountExpiredAuthenticationFailureHandler());
            // 用戶被鎖定時(shí)娜遵,使用的失敗處理
            handlers.put(LockedException.class, new LockedAuthenticationFailureHandler());
            return new DelegatingAuthenticationFailureHandler(handlers, new AuthenticationFailureHandler());
        }
        
      @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .authorizeRequests()
                    .anyRequest().authenticated()
                    .and()
                    .formLogin()
                  // 配置使用自定義失敗處理器
                    .failureHandler(delegatingAuthenticationFailureHandler());
        }
    }
    
  • ExceptionMappingAuthenticationFailureHandler

    @EnableWebSecurity
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
        ...
        @Bean
        public ExceptionMappingAuthenticationFailureHandler exceptionMappingAuthenticationFailureHandler(){
            ExceptionMappingAuthenticationFailureHandler handler = new ExceptionMappingAuthenticationFailureHandler();
            HashMap<String, String> map = new HashMap<>();
            // 登錄失敗時(shí),跳轉(zhuǎn)到 /badCredentials
            map.put(BadCredentialsException.class.getName(), "/badCredentials");
            // 用戶過期時(shí)壤短,跳轉(zhuǎn)到 /accountExpired
            map.put(AccountExpiredException.class.getName(), "/accountExpired");
            // 用戶被鎖定時(shí)设拟,跳轉(zhuǎn)到 /locked
            map.put(LockedException.class.getName(), "/locked");
            handler.setExceptionMappings(map);
            return handler;
        }
        
      @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .authorizeRequests()
                    .anyRequest().authenticated()
                    .and()
                    .formLogin()
                  // 配置使用自定義失敗處理器
                    .failureHandler(exceptionMappingAuthenticationFailureHandler());
        }
    }
    

1.3、認(rèn)證入口

AuthenticationEntryPointSpring Security認(rèn)證入口點(diǎn)接口久脯,在用戶請求處理過程中遇到認(rèn)證異常時(shí)纳胧,使用特定認(rèn)證方式進(jìn)行認(rèn)證。

AuthenticationEntryPoint內(nèi)置實(shí)現(xiàn)類:

  • LoginUrlAuthenticationEntryPoint:根據(jù)配置的登錄頁面url帘撰,將用戶重定向到該登錄頁面進(jìn)行認(rèn)證跑慕。默認(rèn)的認(rèn)證方式。

  • Http403ForbiddenEntryPoint:設(shè)置響應(yīng)狀態(tài)為403摧找,不觸發(fā)認(rèn)證核行。通常在預(yù)身份認(rèn)證中設(shè)置

    在某些情況下,使用Spring Security進(jìn)行授權(quán)蹬耘,但是在訪問該應(yīng)用程序之前芝雪,某些外部系統(tǒng)已經(jīng)對該用戶進(jìn)行了可靠的身份驗(yàn)證。這些情況稱為“預(yù)身份驗(yàn)證(pre-authenticated)”综苔。

  • HttpStatusEntryPoint:設(shè)置特定的響應(yīng)狀態(tài)碼惩系,不觸發(fā)認(rèn)證。

  • BasicAuthenticationEntryPoint:設(shè)置基本(Http Basic)認(rèn)證休里,在響應(yīng)狀態(tài)碼401HeaderWWW-Authenticate:"Basic realm="xxx"時(shí)使用。

  • DigestAuthenticationEntryPoint:設(shè)置摘要(Http Digest)認(rèn)證赃承,在響應(yīng)狀態(tài)碼401HeaderWWW-Authenticate:"Digest realm="xxx"時(shí)使用妙黍。

  • DelegatingAuthenticationEntryPoint:根據(jù)匹配URI來委托給不同的AuthenticationEntryPoint,且必須制定一個(gè)默認(rèn)的認(rèn)證方式瞧剖。

1.3.1拭嫁、自定義AuthenticationEntryPoint

  1. 自定義處理可免,需要新建類實(shí)現(xiàn)該AuthenticationEntryPoint接口:

    public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
         response.setContentType("application/json;charset=UTF-8");
         response.getWriter().append(
                 new ObjectMapper().createObjectNode()
                         .put("status", 401)
                         .put("msg", "未登錄,請登錄后訪問")
                         .toString());
        }
    }
    
  2. WebSecurityConfig配置:

    @EnableWebSecurity
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
        ...       
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            // 指定未登錄入口點(diǎn)
            http.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint());
            ...
        }
    }
    

其它子類的用法:

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    ...
    @Bean
    public DelegatingAuthenticationEntryPoint delegatingAuthenticationEntryPoint() {
        LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> map = new LinkedHashMap<>();
        // GET方式請求/test時(shí)做粤,直接返回 403
        map.put(new AntPathRequestMatcher("/test", "GET"), new Http403ForbiddenEntryPoint());
        // 訪問 /basic時(shí)浇借,直接返回 400 bad request
        map.put(new AntPathRequestMatcher("/basic"), 
                new HttpStatusEntryPoint(HttpStatus.BAD_REQUEST));
        DelegatingAuthenticationEntryPoint entryPoint = new DelegatingAuthenticationEntryPoint(map);
        // 除了上面兩個(gè) uri 配置指定的認(rèn)證入口,其它默認(rèn)使用 LoginUrlAuthenticationEntryPoint認(rèn)證入口
        entryPoint.setDefaultEntryPoint(new LoginUrlAuthenticationEntryPoint("/user-login"));
        return entryPoint;
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        /**
         * Http403ForbiddenEntryPoint 用法
         */
        // http.exceptionHandling()
        //     .authenticationEntryPoint(new Http403ForbiddenEntryPoint());
        /** 
         * HttpStatusEntryPoint 用法
         */
        // http.exceptionHandling()
        //     .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.BAD_REQUEST));
        /**
         * DelegatingAuthenticationEntryPoint 用法
         */
        http.exceptionHandling()
            .authenticationEntryPoint(delegatingAuthenticationEntryPoint());
        ...
    }
}

而對于摘要認(rèn)證DigestAuthenticationEntryPoint怕品,因?yàn)?code>Http摘要認(rèn)證必須基于MD5或明文妇垢,不能使用其它加密方式,且加密方式是MD5(username:realm:password)肉康,所以我們需要手動(dòng)加密用戶密碼:

public int addUser(UserInfo userInfo) throws NoSuchAlgorithmException {
        String username = userInfo.getUsername();
        String password = userInfo.getPassword();

        // 加密密碼
        MessageDigest md5 = MessageDigest.getInstance("MD5");
        String realm = "realm";  // 默認(rèn)是 readlm
        String userData = username + ":" + realm + ":" + password;
        password = new String(Hex.encode(md5.digest(userData.getBytes())));

        userInfo.setPassword(password);
        return userMapper.addUser(userInfo);
}

WebSecurityConfig配置中:

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 因?yàn)橐呀?jīng)使用摘要認(rèn)證MD5加密闯估,不用再加密,所以這里設(shè)置為明文
        return new PasswordEncoder() {
            @Override
            public String encode(CharSequence charSequence) {
                return charSequence.toString();
            }

            @Override
            public boolean matches(CharSequence charSequence, String s) {
                return s.equals(charSequence.toString());
            }
        };
    }
    
    // 摘要認(rèn)證的過濾器
    @Bean
    public DigestAuthenticationFilter digestAuthenticationFilter() {
        DigestAuthenticationFilter filter = new DigestAuthenticationFilter();
        filter.setAuthenticationEntryPoint(digestAuthenticationEntryPoint());//必須配置
        filter.setPasswordAlreadyEncoded(true); // 密碼需要加密吼和,設(shè)為true
        filter.setUserDetailsService(userDetailsService);//必須配置
        return filter;
    }

    @Bean
    public DigestAuthenticationEntryPoint digestAuthenticationEntryPoint() {
        DigestAuthenticationEntryPoint point = new DigestAuthenticationEntryPoint();
        point.setRealmName("realm");//realm名稱涨薪,默認(rèn)為realm,該名稱和加密密碼的realm一樣
        return point;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 使用摘要認(rèn)證的入口
                .exceptionHandling().authenticationEntryPoint(digestAuthenticationEntryPoint())
                .and()
                .authorizeRequests()
                .antMatchers(HttpMethod.POST, "/addUser").permitAll()
                .antMatchers("/hello2").permitAll()
                .anyRequest().authenticated()
                .and()
                .csrf().disable()
                // 摘要認(rèn)證的過濾器
                .addFilter(digestAuthenticationFilter())
}

1.4炫乓、無權(quán)限處理器

自定義處理刚夺,需要新建類實(shí)現(xiàn)該AccessDeniedHandler接口:

public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().append(
                new ObjectMapper().createObjectNode()
                        .put("status", 401)
                        .put("msg", "無訪問權(quán)限")
                        .toString());
    }
}

WebSecurityConfig配置:

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    
    ...    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 先注釋,用登錄頁面登錄
        //http.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint());
        http.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler());
        http
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf().disable();
    }
}

啟動(dòng)程序末捣,訪問localhost:8080/get-user侠姑,跳轉(zhuǎn)登錄頁面,輸入用戶名塔粒、密碼登錄后结借,訪問無權(quán)限的資源,會(huì)返回?zé)o權(quán)限Json信息:

圖片

1.5卒茬、記住登錄

Spring Security記住登錄功能有兩種方式:基于瀏覽器的Cookie存儲(chǔ)和基于數(shù)據(jù)庫的存儲(chǔ)船老。

登錄頁添加記住登錄按鈕

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
    <meta charset="UTF-8">
    <title>登錄頁面</title>
</head>
<body>
<div th:if="${param.error}">
    <p>用戶名或密碼無效</p>
</div>
<form th:action="@{/my-login}" method="post">
    <div><label> 用戶名 : <input type="text" name="username"/> </label></div>
    <div><label> 密碼: <input type="password" name="password"/> </label></div>
    <div>
        <label><input type="checkbox" name="remember-me"/>記住登錄</label>
        <button type="submit">登錄</button>
    </div>
</form>
</body>
</html>

1.5.1、Cookie存儲(chǔ)

WebSecurityConfig配置:

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/user-login").permitAll()
                .loginProcessingUrl("/my-login")
                .successHandler(new CustomAuthenticationSuccessHandler())
                .failureHandler(new CustomAuthenticationFailureHandler())
                .and()
                .rememberMe()
                // 即登錄頁面的記住登錄按鈕的參數(shù)名
                .rememberMeParameter("remember-me")
                // 過期時(shí)間
                .tokenValiditySeconds(1800)
                .and()
                .csrf().disable();
    }
}

啟動(dòng)程序圃酵,在勾選記住登錄下進(jìn)行登錄柳畔,cookie信息如下,remember-me的過期時(shí)間內(nèi)郭赐,重啟瀏覽器訪問不用登錄薪韩。


圖片

1.5.2、數(shù)據(jù)庫存儲(chǔ)

使用 Cookie 存儲(chǔ)雖然很方便捌锭,但是Cookie畢竟是保存在客戶端的俘陷,而且 Cookie 的值還與用戶名、密碼這些敏感數(shù)據(jù)相關(guān)观谦,雖然加密拉盾,但是將敏感信息存在客戶端,畢竟不太安全豁状。

Spring security 還提供了另一種更安全的實(shí)現(xiàn)機(jī)制:在客戶端的 Cookie 中捉偏,僅保存一個(gè)無意義的加密串(與用戶名倒得、密碼等敏感數(shù)據(jù)無關(guān)),然后在數(shù)據(jù)庫中保存該加密串-用戶信息的對應(yīng)關(guān)系夭禽,自動(dòng)登錄時(shí)霞掺,用 Cookie 中的加密串,到數(shù)據(jù)庫中驗(yàn)證讹躯,如果通過菩彬,自動(dòng)登錄才算通過。

WebSecurityConfig 中注入 dataSource 蜀撑,創(chuàng)建一個(gè) PersistentTokenRepositoryBean挤巡,并配置數(shù)據(jù)庫存儲(chǔ)自動(dòng)登錄:

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {    
    @Autowired
    private DataSource dataSource;

    @Bean
    public PersistentTokenRepository persistentTokenRepository(){
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        // 啟動(dòng)時(shí)創(chuàng)建表,注意酷麦,創(chuàng)建好表后矿卑,注釋掉
        // tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login").permitAll()
                .and()
                // 記住登錄
                .rememberMe()
                // 記住我的數(shù)據(jù)存儲(chǔ),調(diào)用上面寫的方法
                .tokenRepository(persistentTokenRepository())
                // 過期時(shí)間
                .tokenValiditySeconds(1800)
                .and()
                .csrf().disable();
    }
}

二沃饶、session管理

在執(zhí)行認(rèn)證過程之前母廷,Spring Security將運(yùn)行SecurityContextPersistenceFilter過濾器負(fù)責(zé)存儲(chǔ)安請求之間的全上下文,上下文根據(jù)策略進(jìn)行存儲(chǔ)糊肤,默認(rèn)為HttpSessionSecurityContextRepository 琴昆,其使用http session作為存儲(chǔ)器。

對于session管理馆揉,有三種:

  1. session超時(shí)處理:session有效的時(shí)間业舍,超時(shí)后刪除
  2. session并發(fā)控制:同個(gè)用戶登錄,是否強(qiáng)制退出前一個(gè)登錄升酣,還是禁止后一個(gè)登錄舷暮。
  3. 集群session管理:默認(rèn)session是放在單個(gè)服務(wù)器的單個(gè)應(yīng)用里,在集群中噩茄,會(huì)出現(xiàn)在一個(gè)節(jié)點(diǎn)應(yīng)用登錄后下面,session只能在該節(jié)點(diǎn)使用。另一個(gè)節(jié)點(diǎn)不能使用其他節(jié)點(diǎn)的session绩聘,還會(huì)需要登錄沥割,所以需要集群共用一個(gè)session

2.1、session超時(shí)

設(shè)置Session的超時(shí)凿菩,很簡單机杜,只需要在配置文件application.yml配置即可,如下為設(shè)置50秒:

  • Springboot2.0前的版本:
spring:
  session:
    timeout: 50
  • Springboot2.0后的版本:
server:
  servlet:
    session:
      timeout: 50

上面設(shè)置Session失效時(shí)間為50s衅谷,實(shí)際源碼TomcatEmbeddedServletContainerFactory類內(nèi)部會(huì)取1分鐘椒拗。源碼內(nèi)部轉(zhuǎn)成分鐘,然后設(shè)置給tomcat原生的StandardContext会喝,所以一般設(shè)置為60秒的整數(shù)倍陡叠。

其實(shí)通過上面配置的點(diǎn)擊進(jìn)去源碼發(fā)現(xiàn):

public void setTimeout(Duration timeout) {
    this.timeout = timeout;
}

參數(shù)傳入的是Duration的實(shí)例,DurationJava8新增的肢执,用來計(jì)算日期差值枉阵,并且是被final聲明,是線程安全的

Duration轉(zhuǎn)換字符串方式预茄,默認(rèn)為正兴溜;負(fù)以-開頭,緊接著P耻陕。

以下字母不區(qū)分大小寫:

  • D:天
  • T:天和小時(shí)之間的分隔符
  • H:小時(shí)
  • M:分鐘
  • S:秒

每個(gè)單位都必須是數(shù)字拙徽,且時(shí)分秒順序不能亂
比如:

  • P2DT3M5S235
  • P3D:3`天
  • PT3H3小時(shí)

所以上面配置文件中可以寫:

server:
  servlet:
    session:
      timeout: PT50S

2.2、session超時(shí)處理

2.2.1诗宣、超時(shí)跳轉(zhuǎn)URL

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            // session無效時(shí)跳轉(zhuǎn)的url
            http.sessionManagement().invalidSessionUrl("/session/invalid");
            http
                .authorizeRequests()
                // 需要放行條跳轉(zhuǎn)的url
                .antMatchers("/session/invalid").permitAll()
                .anyRequest().authenticated()
        }
    }
}

2.2.2膘怕、超時(shí)處理器

session無效時(shí)的處理策略,優(yōu)先級比上面的高

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private CustomInvalidSessionStrategy invalidSessionStrategy;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 設(shè)置session無效處理策略
        http.sessionManagement().invalidSessionStrategy(invalidSessionStrategy);
        http
                .authorizeRequests()
                .antMatchers("/session/invalid").permitAll()
                .anyRequest().authenticated()
    }
}

處理策略:

@Component
public class CustomInvalidSessionStrategy implements InvalidSessionStrategy {
    @Override
    public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
        // 自定義session無效處理
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().append("session無效召庞,請重新登錄");
    }
}

2.3岛心、session并發(fā)控制

默認(rèn)下,我們可以在不同瀏覽器同時(shí)登錄同一個(gè)用戶篮灼,這樣就會(huì)保存了多個(gè)Session忘古,而有時(shí),我們需要只能在一處地方登錄诅诱,其他地方的登錄就讓前一個(gè)失效或不能登錄髓堪。

2.3.1、后登錄致前登錄失效

在一個(gè)瀏覽器登錄后娘荡,再到另一個(gè)瀏覽器登錄干旁,再回到前一個(gè)登錄刷新頁面,登錄失效它改。

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.sessionManagement()
             // 設(shè)置session無效處理策略
            .invalidSessionStrategy(invalidSessionStrategy)
            // 設(shè)置同一個(gè)用戶只能有一個(gè)登陸session
            .maximumSessions(1);
        http
            .authorizeRequests()
            .anyRequest().authenticated();
    }
}

上面設(shè)置maximumSessions設(shè)置為1后疤孕,只能有一個(gè)登錄Session,多個(gè)登錄央拖,后一個(gè)會(huì)把前一個(gè)登錄的Sesson失效祭阀。

而對于前一個(gè)登錄Sesson失效后,刷新頁面會(huì)顯示:

This session has been expired (possibly due to multiple concurrent logins being attempted as the same user).

我們也可以自定義失效返回信息鲜戒,有兩種

  1. 設(shè)置失效session處理URL:

    @EnableWebSecurity
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {  
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.sessionManagement()
                    .invalidSessionStrategy(invalidSessionStrategy)
                    .maximumSessions(1)
                    // 其他地方登錄session失效處理URL
                    .expiredUrl("/session/expired");
            http
                    .authorizeRequests()
                 // URL不需驗(yàn)證
                    .antMatchers("/session/expired").permitAll()
                    .anyRequest().authenticated()
        }
    }
    
  2. 設(shè)置失效session處理策略:

    @EnableWebSecurity
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {  
        @Autowired
        private CustomSessionInformationExpiredStrategy sessionInformationExpiredStrategy;
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http.sessionManagement()
                    .invalidSessionStrategy(invalidSessionStrategy)
                    .maximumSessions(1)
                    // 其他地方登錄session失效處理策略
                    .expiredSessionStrategy(sessionInformationExpiredStrategy);
            http
                    .authorizeRequests()
                    .anyRequest().authenticated()
        }
    }
    

    過期策略:

    @Component
    public class CustomSessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {
        @Override
        public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
            HttpServletResponse response = event.getResponse();
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("當(dāng)前用戶已在其他地方登錄...");
        }
    }
    

2.3.2专控、前登錄禁后登錄

有時(shí),我們在一個(gè)地方登錄正在操作遏餐,不能被打斷伦腐,這時(shí)就要禁止在其他地方登錄導(dǎo)致當(dāng)前的登錄Session失效。

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter { 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.sessionManagement()
            .invalidSessionStrategy(invalidSessionStrategy)
            .maximumSessions(1)
            // 設(shè)置為true失都,即禁止后面其它人的登錄 
            .maxSessionsPreventsLogin(true)
            .expiredSessionStrategy(sessionInformationExpiredStrategy);
        http
            .authorizeRequests()
            .anyRequest().authenticated()
    }
}

禁止后登錄后柏蘑,可以通過如下方式判斷異常進(jìn)行用戶通知:

@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
    @Autowired
    private ObjectMapper objectMapper;
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, 
                                        HttpServletResponse response, 
                                        AuthenticationException exception) {
        response.setContentType("application/json;charset=utf-8");
        if (exception instanceof SessionAuthenticationException){
            response.getWriter().write("用戶已在其它地方登錄幸冻,禁止當(dāng)前登錄...");
        }
    }
}

2.4、集群session管理

在部署應(yīng)用時(shí)咳焚,搭建至少兩臺機(jī)器的集群環(huán)境洽损,防止一臺服務(wù)器出現(xiàn)問題而服務(wù)中斷,這樣在一臺機(jī)器在停止服務(wù)時(shí)革半,另一臺機(jī)器還能繼續(xù)提供服務(wù)碑定。

而使用集群,在基于Session的身份認(rèn)證就會(huì)導(dǎo)致問題:一個(gè)用戶登錄成功后又官,其Session存放在A機(jī)器上延刘,而如果Session不做其他處理,在用戶操作時(shí)六敬,在負(fù)載均衡下碘赖,可能會(huì)請求發(fā)到B機(jī)器上,而B機(jī)器無Session導(dǎo)致無權(quán)限訪問而需要再次登錄外构。

而解決集群中Session的管理崖疤,可以把Session抽取出來為一個(gè)獨(dú)立存儲(chǔ),用戶請求需要Session時(shí)都會(huì)讀取該存儲(chǔ)Session

1585560853325

Spring提供有Spring Session來處理集群Session管理典勇,需要引入如下依賴:

<dependency>
     <groupId>org.springframework.session</groupId>
     <artifactId>spring-session-data-redis</artifactId>
</dependency>
 <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

使用redis作為Session存儲(chǔ)管理劫哼,而Spring Session支持以下方式存儲(chǔ)Session,這里只使用Redis割笙。

public enum StoreType {
    REDIS,
    MONGODB,
    JDBC,
    HAZELCAST,
    NONE;
    private StoreType() {
    }
}

在配置文件application.yml中配置Redis:

spring:
  session:
    store-type: redis   # session存儲(chǔ)類型為 redis
  redis:
    database: 1
    host: localhost
    port: 6379
    # 更新策略权烧,ON_SAVE在調(diào)用#SessionRepository#save(Session)時(shí),在response commit前刷新緩存伤溉,
    # IMMEDIATE只要有任何更新就會(huì)刷新緩存
    flush-mode: on_save  # 默認(rèn)
    # 存儲(chǔ)session的密鑰的命名空間
    namespace: spring:session #默認(rèn)

以不同的端口啟動(dòng)程序般码,如分別以端口80808081啟動(dòng)兩個(gè)服務(wù)。訪問8080端口登錄后乱顾,在訪問8081就不需要登錄了板祝,說明Session被共用了。

二走净、退出登錄

默認(rèn)的退出登錄URL/logout券时,如前面登錄的程序,訪問localhost:8080/logout便退出登錄伏伯,退出登錄后橘洞,默認(rèn)跳轉(zhuǎn)到登錄頁面。

2.1说搅、自定義退出URL

也可通過在WebSecurityConfig進(jìn)行自定義配置:

public class WebSecurityConfig extends WebSecurityConfigurerAdapter {    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .logout()
                // 退出登錄的url, 默認(rèn)為/logout
                .logoutUrl("/logout2")
    }
}

2.2炸枣、退出成功處理

  1. 退出成功處理URL:

    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .logout()
                    // 退出登錄的url, 默認(rèn)為/logout
                    .logoutUrl("/logout2")
                 // 退出成功跳轉(zhuǎn)URL,注意該URL不需要權(quán)限驗(yàn)證
                    .logoutSuccessUrl("/logout/success").permitAll()
        }
    }
    
  2. 退出成功處理器

    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .logout()
                    // 退出登錄的url, 默認(rèn)為/logout
                    .logoutUrl("/logout2")
                 // 退出成功跳轉(zhuǎn)URL,注意該URL不需要權(quán)限驗(yàn)證适肠,所有加.permitAll
                    //.logoutSuccessUrl("/logout/success").permitAll()
                 //退出登錄成功處理器
                    .logoutSuccessHandler(logoutSuccessHandler)
        }
    }
    

    處理器:

    @Component
    public class CustomLogoutSuccessHandler implements LogoutSuccessHandler {
        @Override
        public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write("退出登錄成功");
        }
    }
    

2.3霍衫、退出成功刪除Cookie

默認(rèn)退出后不會(huì)刪除Cookie『钛可配置退出后刪除:

public class WebSecurityConfig extends WebSecurityConfigurerAdapter {    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .logout()
                // 退出登錄的url, 默認(rèn)為/logout
                .logoutUrl("/logout2")
                // 退出成功跳轉(zhuǎn)URL慕淡,注意該URL不需要權(quán)限驗(yàn)證,所有加.permitAll
                //.logoutSuccessUrl("/logout/success").permitAll()
                //退出登錄成功處理器
                .logoutSuccessHandler(logoutSuccessHandler)
                // 退出登錄刪除指定的cookie
                .deleteCookies("JSESSIONID")
    }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末沸毁,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子傻寂,更是在濱河造成了極大的恐慌息尺,老刑警劉巖,帶你破解...
    沈念sama閱讀 222,681評論 6 517
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件疾掰,死亡現(xiàn)場離奇詭異搂誉,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)静檬,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,205評論 3 399
  • 文/潘曉璐 我一進(jìn)店門炭懊,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人拂檩,你說我怎么就攤上這事侮腹。” “怎么了稻励?”我有些...
    開封第一講書人閱讀 169,421評論 0 362
  • 文/不壞的土叔 我叫張陵父阻,是天一觀的道長。 經(jīng)常有香客問我望抽,道長加矛,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 60,114評論 1 300
  • 正文 為了忘掉前任煤篙,我火速辦了婚禮斟览,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘辑奈。我一直安慰自己苛茂,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 69,116評論 6 398
  • 文/花漫 我一把揭開白布鸠窗。 她就那樣靜靜地躺著味悄,像睡著了一般。 火紅的嫁衣襯著肌膚如雪塌鸯。 梳的紋絲不亂的頭發(fā)上侍瑟,一...
    開封第一講書人閱讀 52,713評論 1 312
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼涨颜。 笑死费韭,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的庭瑰。 我是一名探鬼主播星持,決...
    沈念sama閱讀 41,170評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼弹灭!你這毒婦竟也來了督暂?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 40,116評論 0 277
  • 序言:老撾萬榮一對情侶失蹤穷吮,失蹤者是張志新(化名)和其女友劉穎逻翁,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體捡鱼,經(jīng)...
    沈念sama閱讀 46,651評論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡八回,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,714評論 3 342
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了驾诈。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片缠诅。...
    茶點(diǎn)故事閱讀 40,865評論 1 353
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖乍迄,靈堂內(nèi)的尸體忽然破棺而出管引,到底是詐尸還是另有隱情,我是刑警寧澤闯两,帶...
    沈念sama閱讀 36,527評論 5 351
  • 正文 年R本政府宣布汉匙,位于F島的核電站,受9級特大地震影響生蚁,放射性物質(zhì)發(fā)生泄漏噩翠。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 42,211評論 3 336
  • 文/蒙蒙 一邦投、第九天 我趴在偏房一處隱蔽的房頂上張望伤锚。 院中可真熱鬧,春花似錦志衣、人聲如沸屯援。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,699評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽狞洋。三九已至,卻和暖如春绿店,著一層夾襖步出監(jiān)牢的瞬間吉懊,已是汗流浹背庐橙。 一陣腳步聲響...
    開封第一講書人閱讀 33,814評論 1 274
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留借嗽,地道東北人态鳖。 一個(gè)月前我還...
    沈念sama閱讀 49,299評論 3 379
  • 正文 我出身青樓,卻偏偏與公主長得像恶导,于是被迫代替她去往敵國和親浆竭。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,870評論 2 361

推薦閱讀更多精彩內(nèi)容

  • 成功是有方法的惨寿,不信看下面三條邦泄。 一.學(xué)會(huì)靠別人。每個(gè)都有自己的特點(diǎn)裂垦,你有你的顺囊,別人有別人的,你...
    鴻運(yùn)當(dāng)頭168閱讀 304評論 0 1
  • 1)查找被占用的端口: abloume@ubuntu:~$ netstat -tln | grep 8000tcp...
    23d7c1910238閱讀 1,205評論 0 0
  • 作者:詹姆斯缸废,艾倫 001 人的內(nèi)心就好像一個(gè)庭院,它可以長出美麗的花朵驶社,也可以長滿雜草企量。 Sp :除個(gè)草,撒點(diǎn)苗...
    破土的芬芳閱讀 361評論 0 0
  • 傍晚恕汇,幾人散步。 操場很大或辖,綠茵茵的人工草坪頗為寬大瘾英。雖是人工的綠色,也給校園帶來了幾絲生氣颂暇。晚風(fēng)...
    豆小小豆閱讀 289評論 0 0
  • 韓晴是個(gè)小餐廳的職員缺谴, 一天早上, 她在一堆樹枝下發(fā)現(xiàn)了小狗皮皮耳鸯, 蜷縮著的身子湿蛔, 大大的泛著淚光的眼睛, 好像在...
    94暖暖陽光閱讀 307評論 0 2