Java — Spring Boot Todo List Built From Scratch (5)

完成使用登入與取得使用者資訊

Mask
13 min readMay 22, 2023

使用者登入後將會產生一組 Token 並返回,該 Token 會被存放在 Redis,並有相應的 TTL,而取得使用者資訊則是可以用 Token 換取當前使用者的資訊

Photo by Blake Connally on Unsplash

Redis

在開始功能之前,先把 Redis Repo 建立起來,先前在第一篇已完成對 Redis 的連線,現在則是要實作對 Redis 操作的 Repository

目錄結構參考

RedisConfig

RedisTemplate 是 Spring 提供用於與 Redis 交互的工具類,提供常見資料結構的操作函式,主要依靠此類操作 Redis

該類本身是泛型,根據需求初始化成自己需要的類型,除了 <String,String> ,也可以宣告成 <String,Object> 儲存 JSON 格式的值

// RedisConfig.java

@Configuration
public class RedisConfig {

/**
* Redis工具類
* RedisTemplate<String,String> 表示儲存的KV都是Stirng
*/
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory conn) {

RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();

redisTemplate.setConnectionFactory(conn);
// 將Key序列化為String
redisTemplate.setKeySerializer(new StringRedisSerializer());
// 將Value序列化為String
redisTemplate.setValueSerializer(new StringRedisSerializer());

return redisTemplate;
}

}

RedisRepo

使用上方設定的 Redis 工具類函式包裝成 RedisRepo 給其他組件使用,目前還沒有特別需求,就常見的先寫

@Repository
public class RedisRepo {

private final RedisTemplate<String, String> redisTemplate;

@Autowired
public RedisRepo(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}

public void save(String key, String value) {
redisTemplate.opsForValue().set(key, value);
}

public void save(String key, String value, Duration time) {
redisTemplate.opsForValue().set(key, value, time);
}

public String get(String key) {
return redisTemplate.opsForValue().get(key);
}

public void del(String key) {
redisTemplate.delete(key);
}

}

額外紀錄一下 RedisTemplate 提供的函式

  1. 存儲和獲取單個值:opsForValue() 方法提供了對字符串值的存儲和獲取操作,如 set(key, value)get(key)
  2. 存儲和獲取哈希:opsForHash() 方法提供了對哈希數據結構的存儲和獲取操作,如 hset(key, hashKey, value)hget(key, hashKey)
  3. 存儲和獲取列表:opsForList() 方法提供了對列表數據結構的存儲和獲取操作,如 leftPush(key, value)range(key, start, end)
  4. 存儲和獲取集合:opsForSet() 方法提供了對集合數據結構的存儲和獲取操作,如 add(key, value)members(key)
  5. 存儲和獲取有序集合:opsForZSet() 方法提供了對有序集合數據結構的存儲和獲取操作,如 add(key, value, score)rangeByScore(key, min, max)

使用者登入

UserRepo

根據JPA的用法,如果有自定義的欄位要搜尋的話,依照 findByxxxAndxxx 就可以自動產生相對應的功能,欄位名稱與類型需與 Model 相同,並使用大駝峰

// UserRepo.java

@Repository
public interface UserRepo extends CrudRepository<User, Long> {

public User findByAccountAndPwdHash(String account, String pwdHash);

}

UserService

此類的當前的注入情況,加上 final 確保不會再被修改,同時使用 final 的話,注入就需要改成使用建構函式

@Service
public class UserService {

private final UserRepo repo;
private final RedisRepo redisRepo;
private final JwtUtil jwtUtil;

@Autowired
public UserService(UserRepo repo, RedisRepo redisRepo, JwtUtil jwtUtil) {
this.repo = repo;
this.redisRepo = redisRepo;
this.jwtUtil = jwtUtil;
}

//...
}

將 Request 的帳號與密碼 Hash 後,與帳號一同在資料庫搜尋

 // UserService.java

/**
* 登入
*/
public User UserLogin(String account, String password) throws Exception {

String pwdhash = Hash.generateHash(account, password);
User user = repo.findByAccountAndPwdHash(account, pwdhash);

if (user == null) {
throw new UserException().NotFoundUser(new IllegalArgumentException());
}

return user;
}

使用 JwtUtil 產生 token 後存入 Redis

/**
* 產生token
*/
public String GenerateToken(User user) {

// 產生 Token
String token = jwtUtil.generateToken(user);

// 存到Redis
redisRepo.save(user.getId().toString(), token, Duration.ofDays(jwtUtil.expDay));

return token;
}

UserController

組合流程!

/**
* 使用者登入
*/
@PostMapping("/login")
public Response UserLogin(@Valid @RequestBody UserLoginDto data, BindingResult bindingResult) {

if (bindingResult.hasErrors()) {
return new Response().Error().ErrorMessage(bindingResult.getAllErrors());
}

try {

// 使用者登入
User user = userSvc.UserLogin(data.account, data.password);

// 產生 Token
String token = userSvc.GenerateToken(user);

Map<String, String> res = new HashMap<>();
res.put("token", token);

return new Response().AddData(res);
} catch (Exception e) {
return new Response().Error().ErrorMessage(e);
}

}
Photo by Jessica Lewis on Unsplash

使用者資訊

利用 Token 取得當前 Reuqest 的用戶,在前篇中的 JwtAuthFilter 已完成將 Request 中的 Token 驗證與放入 SecurityContextHolder 中,所以只需要使用已經處理好的資料即可

JwtAuthFilter

因為多使用了 Redis,所以 checkToken 流程要多加一個檢查該 Token 是否有在 Redis 中

 // JwtAuthFilter.java

/**
* 檢查Token是否有在Redis中
*/
public void isExistInRedis(String token) throws Exception {
// 取出token中的使用者編號
Long id = extractID(token);
// 查詢redis
String tokenInRedis = redisRepo.get(id.toString());
// 比對token是否相同
if (!tokenInRedis.equals(token)) {
throw new IllegalAccessError();
}

}
 // JwtAuthFilter.java

/**
* 驗證 Token
*/
public boolean checkToken(String token) throws Exception {

try {
// 傳入加密後的JWT Token
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
// 檢查到期日
isExpired(token);
// 檢查是否有在Redis中
isExistInRedis(token);

return true;
} catch (Exception e) {
throw new AuthException().AuthFail(e);
}

}

JwtAuthToken

這個類原先有使用 JwtUtil,但 JwtUtil 作為 Component ,應該是注入給各組件使用,不是在各類自己初始化使用,這樣同樣的對象在系統中會存在很多,而不是共享對象

而 JwtAuthToken 不是 Component,只在需要時調用,對其注入 JwtUtil 不太合理,所以調整了實作函式,不再調用 JwtUtil 取得 Token 內的使用者編號,而是在初始化時就傳入使用者編號

// JwtAuthToken.java

public class JwtAuthToken extends AbstractAuthenticationToken {

private String token;
private Long principal;

public JwtAuthToken(String token, Long id) {
super(null);
this.token = token;
this.principal = id;
}

/**
* 驗證資訊
*/
@Override
public Object getCredentials() {
return token;
}

/**
* 身份資訊,使用者編號
*/
@Override
public Long getPrincipal() {
return this.principal;
}
}

UserService

利用 Token 內的 ID 查詢使用者資訊

// UserService.java

/**
* 取得使用者資訊
*/
public User GetUserInfoById(Long id) throws Exception {

// orElse()用來設置當findById()找不到時的返回結果
// findById()的Optional<T>類是用於處理對象可能為null容器類
User user = repo.findById(id).orElse(null);

if (user == null) {
throw new UserException().NotFoundUser(new IllegalArgumentException());
}

return user;
}

UserController

SecurityContextHolder 取得 Authentication ,此為我自定義 JwtAuthToken 類,所以調用 getPrincipal() 時會回傳類型為 Long 的 id,再調用 UserService 取得使用者資訊

/**
* 取得使用者資訊
*/
@GetMapping("/info")
public Response UserInfo() {

try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Long userId = (Long) auth.getPrincipal();

User user = userSvc.GetUserInfoById(userId);
return new Response().AddData(user);
} catch (Exception e) {

return new Response().Error().ErrorMessage(e);

}

}

寫一個下午的進度,要多花幾個下午才能整理成文章 (汗)

Photo by Borna Bevanda on Unsplash

--

--