密碼加密與微服務鑑權JWT

博客學習目標

一、用戶註冊時候,對數據庫中用戶的密碼進行加密存儲(使用 SpringSecurity)。

二、使用 JWT 鑑權認證。javascript

1、BCrypt 密碼加密

一、常見的加密方式

任何應用考慮到安全,毫不能明文的方式保存密碼。密碼應該經過哈希算法進行加密。
有不少標準的算法好比SHA或者MD5,結合salt(鹽)是一個不錯的選擇。 Spring Security
提供了BCryptPasswordEncoder類,實現Spring的PasswordEncoder接口使用BCrypt強哈希方法來加密數據庫中用戶的密碼。 BCrypt強哈希方法 每次加密的結果都不同。

二、是騾子是馬拉出來遛遛(代碼案例演示)

技術棧:SpringBoot 2.1.6.RELEASE(數據訪問層使用 JPA)

開發工具:IDEA、Java八、Postman前端

引入依賴java

<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 發送用戶註冊請求(以下圖),在查詢數據庫可看到用戶密碼已加密。

5.png

使用 Postman 發送用戶登錄請求(以下圖),返回登錄成功提示。
4.png
所有示例代碼已經上傳到 github ,文末可獲取地址。

2、常見的認證機制

2.一、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讓用戶能夠受權第三方網站訪問他們存儲在另外服務提供者的某些特定信
息,而非全部內容。mysql

下面是OAuth2.0的流程:git

2.png

這種基於OAuth的認證機制適用於我的消費者類的互聯網產品,如社交類APP等應
用,可是不太適合擁有自有認證權限管理的企業應用。github

2.4 Token Auth

使用基於 Token 的身份驗證方法,在服務端不須要存儲用戶的登陸記錄。大概的流程以下:web

  1. 客戶端使用用戶名跟密碼請求登陸。
  2. 服務端收到請求,去驗證用戶名與密碼。
  3. 驗證成功後,服務端會簽發一個 Token,再把這個 Token 發送給客戶端。
  4. 客戶端收到 Token 之後能夠把它存儲起來,好比放在 Cookie 裏。
  5. 客戶端每次向服務端請求資源的時候須要帶着服務端簽發的 Token。
  6. 服務端收到請求,而後去驗證客戶端請求裏面帶着的 Token,若是驗證成功,就向
    客戶端返回請求的數據。

下面是Token Auth 的流程:算法

3.png

重點:Token機制相對於Cookie機制的優缺點?spring

  • 支持跨域訪問: 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 作功能測試的時候,再也不須要
    爲登陸頁面作特殊處理.

3、什麼是 JSON Web Token(JWT)

JWT 格式組成:頭部+載荷+簽名 ( header + payload + signature )
頭部(Header)
頭部用於描述關於該 JWT 的最基本的信息,例如其類型以及簽名所用的算法等。能夠被表示成一個 JSON 對象。例如如下在頭部指明瞭簽名算法是HS256算法。咱們進行BASE64編碼如下內容: {"typ":"JWT","alg":"HS256"},獲得編碼後的字符串以下:

JTdCJTIydHlwJTIyJTNBJTIySldUJTIyJTJDJTIyYWxnJTIyJTNBJTIySFMyNTYlMjIlN0Q=sql

小知識: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了。

4、案例代碼演示

在上面代碼基礎上繼續演示

需求:刪除用戶(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 步驟

一、和上面同樣使用 Postman 註冊一個 Admin 帳戶

二、使用 Postman 模擬訪問登錄,看是否返回 token

三、在使用 Bearer+空格+token,放入頭部刪除用戶,看是否刪除成功。

源碼地址

GitHub地址: https://github.com/RookieMZL/...

歡迎你們指教,提出意見。

相關文章
相關標籤/搜索