官方文档参考,5.1.2 中文参考文档,4.1 中文参考文档,4.1 官方文档中文翻译与源码解读php
SpringSecurity 核心功能:css
pom 依赖html
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.4.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>org.woodwhale.king</groupId>
<artifactId>security-demo</artifactId>
<version>1.0.0</version>
<name>security-demo</name>
<description>spring-security-demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
复制代码
编写一个最简单的用户 controller前端
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping
public String getUsers() {
return "Hello Spring Security";
}
}
复制代码
application.yml 配置IP 和端口java
server:
address: 127.0.0.1
port: 8081
logging:
level:
org.woodwhale.king: DEBUG
复制代码
浏览器访问http://127.0.0.1:8081/user,浏览器被自动重定向到了登陆的界面:mysql
这个/login
访问路径在程序中没有任何的显示代码编写,为何会出现这样的界面呢,当前界面中的UI 都是哪里来的呢?git
固然是 spring-security 进行了默认控制,从启动日志中,能够看到一串用户名默认为user
的默认密码:github
登陆成功以后,能够正常访问服务资源了。web
在配置文件配置用户名和密码:spring
spring:
security:
user:
name: "admin"
password: "admin"
复制代码
旧版的 spring security 关闭默认安全访问控制,只须要在配置文件中关闭便可:
security.basic.enabled = false
复制代码
新版本 Spring-Boot2.xx(Spring-security5.x) 的再也不提供上述配置了:
方法1: 将 security 包从项目依赖中去除。
方法2:将org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
不注入spring中:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
@SpringBootApplication
@EnableAutoConfiguration(exclude = {SecurityAutoConfiguration.class})
public class SecurityDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityDemoApplication.class, args);
}
}
复制代码
方法3:己实现一个配置类继承自WebSecurityConfigurerAdapter
,并重写configure(HttpSecurity http)
方法:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/**").permitAll();
}
/** * 配置一个userDetailsService Bean * 再也不生成默认security.user用户 */
@Bean
@Override
protected UserDetailsService userDetailsService() {
return super.userDetailsService();
}
}
复制代码
注意:WebSecurityConfigurerAdapter
是一个适配器类,因此为了使自定义的配置类见名知义,因此写成了WebSecurityConfig
。同时增长了@EnableWebSecurity
注解到了 spring security 中。
springsucrity 的自定义用户认证配置的核心均在上述的WebSecurityConfigurerAdapter
类中,用户想要个性化的用户认证逻辑,就须要本身写一个自定义的配置类,适配到 spring security 中:
注意:若是配置了两个以上的自定义实现类,那么就会报WebSecurityConfigurers
不惟一的错误:java.lang.IllegalStateException: @Order on WebSecurityConfigurers must be unique.
@Configuration
@EnableWebSecurity
public class BrowerSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() // 定义当须要提交表单进行用户登陆时候,转到的登陆页面。
.and()
.authorizeRequests() // 定义哪些URL须要被保护、哪些不须要被保护
.anyRequest() // 任何请求,登陆后能够访问
.authenticated();
}
}
复制代码
将用户名密码设置到内存中,用户登陆的时候会校验内存中配置的用户名和密码:
在旧版本的 spring security 中,在上述自定义的BrowerSecurityConfig
中配置以下代码便可:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("admin").password("admin").roles("ADMIN");
}
复制代码
可是在新版本中,启动运行都没有问题,一旦用户正确登陆的时候,会报异常:
java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
复制代码
由于在 Spring security 5.0 中新增了多种加密方式,也改变了密码的格式。官方文档说明:Password Storage Format
上面这段话的意思是,如今新的 Spring Security 中对密码的存储格式是"{id}……"
。前面的 id
是加密方式,id 能够是bcrypt
、sha256
等,后面紧跟着是使用这种加密类型进行加密后的密码。
所以,程序接收到内存或者数据库查询到的密码时,首先查找被{}
包括起来的id
,以肯定后面的密码是被什么加密类型方式进行加密的,若是找不到就认为 id 是 null。这也就是为何程序会报错:There is no PasswordEncoder mapped for the id "null"
。官方文档举的例子中是各类加密方式针对同一密码加密后的存储形式,原始密码都是"password"。
要想咱们的项目还可以正常登录,须要将前端传过来的密码进行某种方式加密,官方推荐的是使用bcrypt
加密方式(不用用户使用相同原密码生成的密文是不一样的),所以须要在 configure 方法里面指定一下:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// auth.inMemoryAuthentication().withUser("admin").password("admin").roles("ADMIN");
auth.inMemoryAuthentication()
.passwordEncoder(new BCryptPasswordEncoder())
.withUser("admin")
.password(new BCryptPasswordEncoder().encode("admin"))
.roles("ADMIN");
}
复制代码
固然还有一种方法,将passwordEncoder
配置抽离出来:
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
复制代码
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("admin")
.password(new BCryptPasswordEncoder().encode("admin"))
.roles("ADMIN");
}
复制代码
这里还有一种更优雅的方法,实现org.springframework.security.core.userdetails.UserDetailsService
接口,重载loadUserByUsername(String username)
方法,当用户登陆时,会调用UserDetailsService
接口的loadUserByUsername()
来校验用户的合法性(密码和权限)。
这种方法为以后结合数据库或者JWT动态校验打下技术可行性基础。
@Service
public class MyUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ADMIN"));
return new User("root", new BCryptPasswordEncoder().encode("root"), authorities);
}
}
复制代码
固然,"自定义到内存"中的配置文件中的configure(AuthenticationManagerBuilder auth)
配置就不须要再配置一遍了。
注意: 对于返回的UserDetails
实现类,可使用框架本身的 User,也能够本身实现一个 UserDetails 实现类,其中密码和权限都应该从数据库中读取出来,而不是写死在代码里。
将加密类型抽离出来,实现UserDetailsService
接口,将二者注入到AuthenticationManagerBuilder
中:
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
}
复制代码
UserDetailsService
接口实现类:
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class MyUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ADMIN"));
return new User("root", new BCryptPasswordEncoder().encode("root"), authorities);
}
}
复制代码
这里的 User 对象是框架提供的一个用户对象,注意包名是:org.springframework.security.core.userdetails.User
,里面的属性中最核心的就是password
,username
和authorities
。
配置自定义的登陆页面:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() // 定义当须要用户登陆时候,转到的登陆页面。
.loginPage("/login") // 设置登陆页面
.loginProcessingUrl("/user/login") // 自定义的登陆接口
.defaultSuccessUrl("/home").permitAll() // 登陆成功以后,默认跳转的页面
.and().authorizeRequests() // 定义哪些URL须要被保护、哪些不须要被保护
.antMatchers("/", "/index","/user/login").permitAll() // 设置全部人均可以访问登陆页面
.anyRequest().authenticated() // 任何请求,登陆后能够访问
.and().csrf().disable(); // 关闭csrf防御
}
复制代码
从上述配置中,能够看出用能够全部访客都可以自由登陆/
和/index
进行资源访问,同时配置了一个登陆的接口/lgoin
,使用mvc作了视图映射(映射到模板文件目录中的login.html
),controller 映射代码太简单就不赘述了,当用户成功登陆以后,页面会自动跳转至/home
页面。
上述图片中的配置有点小小缺陷,当去掉
.loginProcessUrl()
的配置的时候,登陆完毕,浏览器会一直重定向,直至报重定向失败。由于登陆成功的 url 没有配置成全部人都可以访问,所以形成了死循环的结果。所以,配置了登陆界面就须要配置任意可访问:
.antMatchers("/user/login").permitAll()
login.html
代码:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>登陆页面</title>
</head>
<body>
<h2>自定义登陆页面</h2>
<form action="/user/login" method="post">
<table>
<tr>
<td>用户名:</td>
<td><input type="text" name="username"></td>
</tr>
<tr>
<td>密码:</td>
<td><input type="password" name="password"></td>
</tr>
<tr>
<td colspan="2"><button type="submit">登陆</button></td>
</tr>
</table>
</form>
</body>
</html>
复制代码
上述配置用户认证过程当中,会发现资源文件也被安全框架挡在了外面,所以须要进行安全配置:
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/webjars/**/*", "/**/*.css", "/**/*.js");
}
复制代码
如今前端框架的静态资源彻底能够经过webjars
统一管理,所以注意配置/webjars/**/*
。
先后端分离的系统中,通常后端仅提供接口 JSON 格式的数据,以供前端自行调用。刚才那样,调用了被保护的接口,直接进行了页面的跳转,在web端还能够接受,可是在 App 端就不行了, 因此咱们还须要作进一步的处理。 这里作一下简单的思路整理
这里提供一种思路,核心在于运用安全框架的:RequestCache
和RedirectStrategy
import java.io.IOException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RestController
public class BrowserSecurityController {
// 原请求信息的缓存及恢复
private RequestCache requestCache = new HttpSessionRequestCache();
// 用于重定向
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
/** * 当须要身份认证的时候,跳转过来 * @param request * @param response * @return */
@RequestMapping("/authentication/require")
@ResponseStatus(code = HttpStatus.UNAUTHORIZED)
public String requireAuthenication(HttpServletRequest request, HttpServletResponse response) throws IOException {
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
String targetUrl = savedRequest.getRedirectUrl();
log.info("引起跳转的请求是:" + targetUrl);
if (StringUtils.endsWithIgnoreCase(targetUrl, ".html")) {
redirectStrategy.sendRedirect(request, response, "/login.html");
}
}
return "访问的服务须要身份认证,请引导用户到登陆页";
}
}
复制代码
注意: 这个/authentication/require
须要配置到安全认证配置:配置成默认登陆界面,并设置成任何人都可以访问,而且这个重定向的页面能够设计成配置,从配置文件中读取。
在先后端分离的状况下,咱们登陆成功了可能须要向前端返回用户的我的信息,而不是直接进行跳转。登陆失败也是一样的道理。这里涉及到了 Spring Security 中的两个接口AuthenticationSuccessHandler
和AuthenticationFailureHandler
。自定义这两个接口的实现,并进行相应的配置就能够了。 固然框架是有默认的实现类的,咱们能够继承这个实现类再来自定义本身的业务:
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component("myAuthenctiationSuccessHandler")
public class MyAuthenctiationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("登陆成功");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(authentication));
}
}
复制代码
成功登陆以后,经过 response 返回一个 JSON 字符串回去。这个方法中的第三个参数Authentication
,它里面包含了登陆后的用户信息(UserDetails),Session 的信息,登陆信息等。
登陆成功以后的响应JSON:
{
"authorities": [
{
"authority": "ROLE_admin"
}
],
"details": {
"remoteAddress": "127.0.0.1",
"sessionId": "8BFA4F61A7CEA774C00F616AAE8C307C"
},
"authenticated": true,
"principal": {
"password": null,
"username": "admin",
"authorities": [
{
"authority": "ROLE_admin"
}
],
"accountNonExpired": true,
"accountNonLocked": true,
"credentialsNonExpired": true,
"enabled": true
},
"credentials": null,
"name": "admin"
}
复制代码
这里有个细节须要注意:
principal
中有个权限数组集合authorities
,里面的权限值是:ROLE_admin
,而自定义的安全认证配置中配置的是:admin
,因此ROLE_
前缀是框架本身加的,后期取出权限集合的时候须要注意这个细节,以取决于判断是否有权限是使用字符串的包含关系仍是等值关系。
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component("myAuthenctiationFailureHandler")
public class MyAuthenctiationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
log.info("登陆失败");
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(exception.getMessage()));
}
}
复制代码
将两个自定义的处理类配置到自定义配置文件中:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.woodwhale.king.handler.MyAuthenctiationFailureHandler;
import org.woodwhale.king.handler.MyAuthenctiationSuccessHandler;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyAuthenctiationFailureHandler myAuthenctiationFailureHandler;
@Autowired
private MyAuthenctiationSuccessHandler myAuthenctiationSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() // 定义当须要用户登陆时候,转到的登陆页面。
.loginPage("/login") // 设置登陆页面
.loginProcessingUrl("/user/login") // 自定义的登陆接口
.successHandler(myAuthenctiationSuccessHandler)
.failureHandler(myAuthenctiationFailureHandler)
//.defaultSuccessUrl("/home").permitAll() // 登陆成功以后,默认跳转的页面
.and().authorizeRequests() // 定义哪些URL须要被保护、哪些不须要被保护
.antMatchers("/", "/index").permitAll() // 设置全部人均可以访问登陆页面
.anyRequest().authenticated() // 任何请求,登陆后能够访问
.and().csrf().disable(); // 关闭csrf防御
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.passwordEncoder(new BCryptPasswordEncoder()).withUser("admin")
.password(new BCryptPasswordEncoder().encode("admin"))
.roles("admin");
}
}
复制代码
注意: defaultSuccessUrl
不须要再配置了,实测若是配置了,成功登陆的 handler 就不起做用了。
小结
能够看出,经过自定义的登陆成功或者失败类,进行登陆响应控制,能够设计一个配置,以灵活适配响应返回的是页面仍是 JSON 数据。
在前端使用了Thymeleaf
进行渲染,特使是结合Spring Security
在前端获取用户信息
依赖添加:
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
复制代码
注意:
由于本项目使用了spring boot 自动管理版本号,因此引入的必定是彻底匹配的,若是是旧的 spring security 版本须要手动引入对应的版本。
引用官方版本引用说明:
thymeleaf-extras-springsecurity3 for integration with Spring Security 3.x
thymeleaf-extras-springsecurity4 for integration with Spring Security 4.x
thymeleaf-extras-springsecurity5 for integration with Spring Security 5.x
复制代码
具体语法可查看: github.com/thymeleaf/t…
这里为了表述方便,引用了上小节中的"自定义处理登陆成功/失败"的成功响应JSON数据:
{
"authorities": [
{
"authority": "ROLE_admin"
}
],
"details": {
"remoteAddress": "127.0.0.1",
"sessionId": "8BFA4F61A7CEA774C00F616AAE8C307C"
},
"authenticated": true,
"principal": {
"password": null,
"username": "admin",
"authorities": [
{
"authority": "ROLE_admin"
}
],
"accountNonExpired": true,
"accountNonLocked": true,
"credentialsNonExpired": true,
"enabled": true
},
"credentials": null,
"name": "admin"
}
复制代码
sec:authorize="isAuthenticated()
:判断是否有认证经过
sec:authorize="hasRole('ROLE_ADMIN')"
判断是否有ROLE_ADMIN
权限
注意: 上述的hasRole()
标签使用能成功的前提是:自定义用户的权限字符集必须是以ROLE_
为前缀的,不然解析不到,即自定义的UserDetailsService
实现类的返回用户的权限数组列表的权限字段必须是ROLE_***
,同时在 html 页面中注意引入对应的xmlns
,本例这里引用了:
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
复制代码
sec:authentication="principal.authorities"
:获得该用户的全部权限列表
sec:authentication="principal.username"
:获得该用户的用户名
固然也能够获取更多的信息,只要UserDetailsService
实现类中返回的用户中携带有的信息都可以获取。
AuthenticationException 经常使用的的子类:(会被底层换掉,不推荐使用)
UsernameNotFoundException 用户找不到
BadCredentialsException 坏的凭据
AccountStatusException 用户状态异常它包含以下子类:(推荐使用)
AccountExpiredException 帐户过时
LockedException 帐户锁定
DisabledException 帐户不可用
CredentialsExpiredException 证书过时
复制代码
参考资料:
参考项目源码:
我的博客:woodwhale's blog
博客园:木鲸鱼的博客