博客學習目標
1、用戶註冊時候,對數據庫中用戶的密碼進行加密存儲(使用 SpringSecurity)。
2、使用 JWT 鑑權認證。
一、BCrypt 密碼加密
1、常見的加密方式
任何應用考慮到安全,絕不能明文的方式保存密碼。密碼應該通過哈希算法進行加密。
有很多標準的算法比如SHA或者MD5,結合salt(鹽)是一個不錯的選擇。 Spring Security
提供了BCryptPasswordEncoder類,實現Spring的PasswordEncoder接口使用BCrypt強哈希方法來加密數據庫中用戶的密碼。BCrypt強哈希方法 每次加密的結果都不一樣。
2、是騾子是馬拉出來遛遛(代碼案例演示)
技術棧:SpringBoot 2.1.6.RELEASE(數據訪問層使用 JPA)
開發工具:IDEA、Java8、Postman
引入依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- 引入 SpringSecurity -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- lombok工具 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
控制層 controller
@RestController
@CrossOrigin
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
// 用戶註冊
@RequestMapping(value = "/register", method = RequestMethod.POST)
public Result register(@RequestBody User user) {
boolean isRegister = userService.register(user);
if (!isRegister) {
return new Result(false, StatusCode.ERROR, "手機號碼已經被註冊,請直接登陸!");
}
return new Result(true, StatusCode.OK, "註冊成功!");
}
// 用戶登陸(限定使用手機號和密碼登錄)
@RequestMapping(value = "/login", method = RequestMethod.POST)
public Result login(@RequestBody User user) {
User loginUser = userService.login(user.getMobile(), user.getPassword());
if (null == loginUser) {
return new Result(false, StatusCode.LOGINERROR, "登陸失敗,請檢查手機號或者密碼是否正確.");
}
return new Result(true, StatusCode.OK, "登陸成功.");
}
}
業務處理層 service
@Service
public class UserService {
@Autowired
private UserDao userDao;
@Autowired
private BCryptPasswordEncoder encoder;
// 用戶註冊功能
public boolean register(User user) {
User existUser = userDao.findByMobile(user.getMobile());
if (null == existUser) {
user.setId(UUIDUtil.getUUID())
.setPassword(encoder.encode(user.getPassword())) // 密碼加密
.setFollowcount(0)
.setFanscount(0)
.setOnline(0L)
.setRegdate(new Date())
.setUpdatedate(new Date())
.setLastdate(new Date());
userDao.save(user);
return true;
}
return false;
}
// 用戶登陸(限定使用手機號和密碼登錄)
public User login(String mobile, String password) {
User existUser = userDao.findByMobile(mobile);
if (null != existUser && encoder.matches(password, existUser.getPassword())) {
return existUser;
}
return null;
}
}
數據庫訪問層 dao
public interface UserDao extends JpaRepository<User, String>, JpaSpecificationExecutor<User> {
// 判斷用戶手機號是否已經註冊
User findByMobile(String mobile);
}
啓動類注入 BCryptPasswordEncoder
@SpringBootApplication
public class BcryptJwtApplication {
public static void main(String[] args) {
SpringApplication.run(BcryptJwtApplication.class, args);
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
SpringSecurity 安全配置類,對路徑攔截。
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
//authorizeRequests 所有 security 全註解配置實現的開端,表示開始說明需要的權限
//需要的權限分兩部分,第一部分是攔截的路徑,第二部分訪問該路徑需要的權限
//antMarcher表示攔截說明路徑,permitAll任何權限都可以訪問,直接放行所有
//anyRequest()任何請求,authenticated認證後才能訪問
//.and.csrf.disable(),固定寫法,表示使用csrf攔截失敗
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/**").permitAll()
.anyRequest().authenticated()
.and().csrf().disable();
}
}
使用 Postman 發送用戶註冊請求(如下圖),在查詢數據庫可看到用戶密碼已加密。
使用 Postman 發送用戶登陸請求(如下圖),返回登陸成功提示。
全部示例代碼已經上傳到 github ,文末可獲取地址。
二、常見的認證機制
2.1、HTTP Basic Auth
HTTP Basic Auth簡單點說就是每次請求API時都提供用戶的username和
password,簡言之,Basic Auth是配合RESTful API 使用的最簡單的認證方式,只需提供
用戶名密碼即可,但由於有把用戶名密碼暴露給第三方客戶端的風險,在生產環境下被
使用的越來越少。因此,在開發對外開放的RESTful API時,儘量避免採用HTTP Basic
Auth
2.2 Cookie Auth
Cookie認證機制就是爲一次請求認證在服務端創建一個Session對象,同時在客戶端
的瀏覽器端創建了一個Cookie對象;通過客戶端帶上來Cookie對象來與服務器端的
session對象匹配來實現狀態管理的。默認的,當我們關閉瀏覽器的時候,cookie會被刪
除。但可以通過修改cookie 的expire time使cookie在一定時間內有效;
2.3 OAuth
OAuth(開放授權)是一個開放的授權標準,允許用戶讓第三方應用訪問該用戶在
某一web服務上存儲的私密的資源(如照片,視頻,聯繫人列表),而無需將用戶名和
密碼提供給第三方應用。OAuth允許用戶提供一個令牌,而不是用戶名和密碼來訪問他們存放在特定服務提
供者的數據。每一個令牌授權一個特定的第三方系統(例如,視頻編輯網站)在特定的時
段(例如,接下來的2小時內)內訪問特定的資源(例如僅僅是某一相冊中的視頻)。這
樣,OAuth讓用戶可以授權第三方網站訪問他們存儲在另外服務提供者的某些特定信
息,而非所有內容。下面是OAuth2.0的流程:
這種基於OAuth的認證機制適用於個人消費者類的互聯網產品,如社交類APP等應
用,但是不太適合擁有自有認證權限管理的企業應用。
2.4 Token Auth
使用基於 Token 的身份驗證方法,在服務端不需要存儲用戶的登錄記錄。大概的流程如下:
- 客戶端使用用戶名跟密碼請求登錄。
- 服務端收到請求,去驗證用戶名與密碼。
- 驗證成功後,服務端會簽發一個 Token,再把這個 Token 發送給客戶端。
- 客戶端收到 Token 以後可以把它存儲起來,比如放在 Cookie 裏。
- 客戶端每次向服務端請求資源的時候需要帶着服務端簽發的 Token。
- 服務端收到請求,然後去驗證客戶端請求裏面帶着的 Token,如果驗證成功,就向
客戶端返回請求的數據。下面是Token Auth 的流程:
重點:Token機制相對於Cookie機制的優缺點?
- 支持跨域訪問: Cookie是不允許垮域訪問的,這一點對Token機制是不存在的,前提
是傳輸的用戶認證信息通過HTTP頭傳輸. - 無狀態(也稱:服務端可擴展行):Token機制在服務端不需要存儲session信息,因爲
Token 自身包含了所有登錄用戶的信息,只需要在客戶端的cookie或本地介質存儲
狀態信息. - 更適用CDN: 可以通過內容分發網絡請求你服務端的所有資料(如:javascript,
HTML,圖片等),而你的服務端只要提供API即可. - 去耦: 不需要綁定到一個特定的身份驗證方案。Token可以在任何地方生成,只要在
你的API被調用的時候,你可以進行Token生成調用即可. - 更適用於移動應用: 當你的客戶端是一個原生平臺(iOS, Android,Windows 8等)
時,Cookie是不被支持的(你需要通過Cookie容器進行處理),這時採用Token認
證機制就會簡單得多。 - CSRF:因爲不再依賴於Cookie,所以你就不需要考慮對CSRF(跨站請求僞造)的防
範。 - 性能: 一次網絡往返時間(通過數據庫查詢session信息)總比做一次HMACSHA256
計算 的Token驗證和解析要費時得多. - 不需要爲登錄頁面做特殊處理: 如果你使用Protractor 做功能測試的時候,不再需要
爲登錄頁面做特殊處理.
三、什麼是 JSON Web Token(JWT)
JWT 格式組成:頭部+載荷+簽名 ( header + payload + signature )
頭部(Header)
頭部用於描述關於該 JWT 的最基本的信息,例如其類型以及簽名所用的算法等。可以被表示成一個 JSON 對象。例如以下在頭部指明瞭簽名算法是HS256算法。我們進行BASE64編碼以下內容:
{"typ":"JWT","alg":"HS256"}
,得到編碼後的字符串如下:
JTdCJTIydHlwJTIyJTNBJTIySldUJTIyJTJDJTIyYWxnJTIyJTNBJTIySFMyNTYlMjIlN0Q=
小知識:Base64是一種基於64個可打印字符來表示二進制數據的表示方法。由於2
的6次方等於64,所以每6個比特爲一個單元,對應某個可打印字符。三個字節有24
個比特,對應於4個Base64單元,即3個字節需要用4個可打印字符來表示。JDK 中
提供了非常方便的 BASE64Encoder 和 BASE64Decoder,用它們可以非常方便的
完成基於 BASE64 的編碼和解碼。
載荷(playload)
載荷就是存放有效信息的地方。這個名字像是特指飛機上承載的貨品,這些有效信息包含三個部分:
(1) 標準中註冊的聲明(建議但不強制使用)
- iss: jwt簽發者。
- sub: jwt所面向的用戶。
- aud: 接收jwt的一方 。
- exp: jwt的過期時間,這個過期時間必須要大於簽發時間 。
- nbf: 定義在什麼時間之前,該jwt都是不可用的.。
- iat: jwt的簽發時間 。
- jti: jwt的唯一身份標識,主要用來作爲一次性token,從而回避重放攻擊。
(2) 公共的聲明
公共的聲明可以添加任何的信息,一般添加用戶的相關信息或其他業務需要的必要信息.
但不建議添加敏感信息,因爲該部分在客戶端可解密。(3) 私有聲明
私有聲明是提供者和消費者所共同定義的聲明,一般不建議存放敏感信息,因爲base64
是對稱解密的,意味着該部分信息可以歸類爲明文信息。定義一個payload:
{"sub":"1234567890","name":"John Doe","admin":true}
,然後將其進行base64編碼,得到 Jwt 的第二部分如下:JTdCJTIyc3ViJTIyJTNBJTIyMTIzNDU2Nzg5MCUyMiUyQyUyMm5hbWUlMjIlM0ElMjJKb2huJUEwRG9lJTIyJTJDJTIyYWRtaW4lMjIlM0F0cnVlJTdE
簽名(signature)
jwt的第三部分是一個簽證信息,這個簽證信息由三部分組成:
header (base64後的)
。payload (base64後的)。
secret。
這個部分需要base64加密後的header和base64加密後的payload使用,連接組成的字符串,然後通過header中聲明的加密方式進行加鹽secret組合加密,然後就構成了jwt的第三部分如下:(就是使用頭部指明的簽名算法對已經加密了以後的字符串在進行加密得到第三部分)
TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
將這三部分用連接成一個完整的字符串,構成了最終的jwt如下:
JTdCJTIydHlwJTIyJTNBJTIySldUJTIyJTJDJTIyYWxnJTIyJTNBJTIySFMyNTYlMjIlN0Q=.JTdCJTIyc3ViJTIyJTNBJTIyMTIzNDU2Nzg5MCUyMiUyQyUyMm5hbWUlMjIlM0ElMjJKb2huJUEwRG9lJTIyJTJDJTIyYWRtaW4lMjIlM0F0cnVlJTdE.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
知識點1:
Signature 部分是對前兩部分的簽名,防止數據篡改。首先需要指定一個密鑰(secret)。這個密鑰只有服務器才知道,不能泄露給用戶。然後使用 Header 裏面指定的簽名算法,按照下面的公式產生簽名: HMACSHA256(base64UrlEncode(header) + “.” + base64UrlEncode(payload), secret)
。
算出簽名以後,把 Header、Payload、Signature 三個部分拼成一個字符串,每個部分之間用(.)分隔,就可以返回給用戶。
知識點2:
secret是保存在服務器端的,jwt的簽發生成也是在服務器端的,secret就是用來進行jwt的簽發和jwt的驗證,所以它就是你服務端的私鑰,在任何場景都不應該流露出去。一旦客戶端得知這個secret, 那就意味着客戶端是可以自我簽發jwt了。
四、案例代碼演示
在上面代碼基礎上繼續演示
需求:刪除用戶(User),必須擁有管理員(Admin)權限,否則不能刪除。
前後端約定:前端請求後端時需要添加頭信息 Authorization ,內容爲Bearer+空格
+token
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.6.0</version>
</dependency>
用戶生成 、解析 token 的工具類
@ConfigurationProperties("jwt.config")
public class JwtUtil {
private String key;
private long ttl;//一個小時
public String getKey() {return key;}
public void setKey(String key) {this.key = key;}
public long getTtl() {return ttl;}
public void setTtl(long ttl) {this.ttl = ttl;}
// 生成JWT
public String createJWT(String id, String subject, String roles) {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
JwtBuilder builder = Jwts.builder().setId(id)
.setSubject(subject)
.setIssuedAt(now)
.signWith(SignatureAlgorithm.HS256, key).claim("roles", roles);
if (ttl > 0) {
builder.setExpiration(new Date(nowMillis + ttl));
}
return builder.compact();
}
// 解析JWT
public Claims parseJWT(String jwtStr) {
return Jwts.parser()
.setSigningKey(key)
.parseClaimsJws(jwtStr)
.getBody();
}
}
啓動類注入 JwtUtil 工具類
@SpringBootApplication
public class BcryptJwtApplication {
public static void main(String[] args) {
SpringApplication.run(BcryptJwtApplication.class, args);
}
@Bean
public JwtUtil jwtUtil() {
return new JwtUtil();
}
}
創建攔截器類
如果每個方法都去寫一段代碼驗證用戶登陸 token 的正確性,冗餘度太高不利於維護。我們可以將這段代碼放入攔截器去實現同意攔截,再判斷用戶 token。
Spring爲我提供了
org.springframework.web.servlet.handler.HandlerInterceptorAdapter 這個適配器,
繼承此類,可以非常方便的實現自己的攔截器。他有三個方法:
分別實現預處理、後處理(調用了Service並返回ModelAndView,但未進行頁面渲
染)、返回處理(已經渲染了頁面)。
在preHandle中,可以進行編碼、安全控制等處理;
在postHandle中,有機會修改ModelAndView;
在afterCompletion中,可以根據ex是否爲null判斷是否發生了異常,進行日誌記錄。
@Component
public class JwtInterceptor extends HandlerInterceptorAdapter {
@Autowired
private JwtUtil jwtUtil;
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
System.out.println("經過攔截器");
final String authHeader = request.getHeader("Authorization");//獲取頭信息
if (authHeader != null && authHeader.startsWith("Bearer ")) { // 注意是 Bearer + 空格
final String token = authHeader.substring(7);
Claims claims = jwtUtil.parseJWT(token);
if (claims != null) {
if ("admin".equals(claims.get("roles"))) {//如果是管理員
request.setAttribute("admin_claims", claims);
}
if ("user".equals(claims.get("roles"))) {//如果是普通用戶
request.setAttribute("user_claims", claims);
}
}
}
return true;
}
}
配置攔截器類
@Configuration
public class ApplicationConfig extends WebMvcConfigurationSupport {
@Autowired
private JwtInterceptor jwtInterceptor;
protected void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/**/login");
}
}
控制層 controller
@RestController
@CrossOrigin
@RequestMapping("/admin")
public class AdminController {
@Autowired
private AdminService adminService;
@Autowired
private JwtUtil jwtUtil;
// Admin 用戶登陸
@RequestMapping(value = "/login", method = RequestMethod.POST)
public Result login(@RequestBody Admin admin) {
Admin loginUser = adminService.findByLoginNameAndPassword(admin.getLoginname(), admin.getPassword());
if (null == loginUser) {
return new Result(false, StatusCode.LOGINERROR, "登陸失敗,請檢查用戶名或者密碼是否正確");
}
// 生成令牌,並且返回給前臺
String token = jwtUtil.createJWT(loginUser.getId(), loginUser.getLoginname(), "admin");
Map<String, Object> map = new HashMap<>();
map.put("token", token);
map.put("role", "admin");
map.put("name", loginUser.getLoginname());
return new Result(true, StatusCode.OK, "登陸成功", map);
}
// 添加 Admin 用戶
@RequestMapping(value = "/add", method = RequestMethod.POST)
public Result add(@RequestBody Admin admin) {
adminService.add(admin);
return new Result(true, StatusCode.OK, "增加成功");
}
}
業務處理層 service
@Service
public class AdminService {
@Autowired
private AdminDao adminDao;
@Autowired
private BCryptPasswordEncoder encoder;
// 根據登陸用戶名和密碼查詢
public Admin findByLoginNameAndPassword(String loginName, String password) {
Admin admin = adminDao.findByLoginname(loginName);
if (null != admin && encoder.matches(password, admin.getPassword())) {
return admin;
}
return null;
}
// 添加管理員
public void add(Admin admin) {
admin.setId(UUIDUtil.getUUID()); // 主鍵
// 密碼加密
String newPassword = encoder.encode(admin.getPassword());
admin.setPassword(newPassword);
adminDao.save(admin);
}
}
數據訪問層 dao
public interface AdminDao extends JpaRepository<Admin, String>, JpaSpecificationExecutor<Admin> {
// 管理員登陸校驗
Admin findByLoginname(String loginName);
}
修改UserController的delete方法
@RestController
@CrossOrigin
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@Autowired
private HttpServletRequest servletRequest;
/**
* 刪除:刪除用戶,必須擁有管理員權限,否則不能刪除
* <p>
* 前後端約定:前端請求微服務時需要添加頭信息Authorization ,內容爲Bearer+空格+token
*
* @param id
*/
@RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
public Result delete(@PathVariable String id) {
Claims claims = (Claims) servletRequest.getAttribute("admin_claims");
if (null == claims) {
return new Result(true, StatusCode.ACCESSERROR, "無權訪問");
}
userService.deleteById(id);
return new Result(true, StatusCode.OK, "刪除成功");
}
}
測試生成 token 步驟
1、和上面一樣使用 Postman 註冊一個 Admin 賬戶
2、使用 Postman 模擬訪問登陸,看是否返回 token
3、在使用
Bearer+空格+token
,放入頭部刪除用戶,看是否刪除成功。
源碼地址
GitHub地址: https://github.com/RookieMZL/practice-sample/tree/dev/bcrypt-jwt
歡迎大家指教,提出意見。