標題起的有點問題,因爲統一登陸不一定實在同一個項目的不同模塊,也可以是不同項目的不同模塊。既可以是一個大的springcloud項目,也可以是多個分屬於不同項目的springboot項目。
1.首先要理解何爲統一登陸?
統一登陸可以通過一個具體的app來實現。下面是一張美團app的首頁截圖。
首頁上面的美食,電影演出,酒店住宿,休閒娛樂,外賣五大模塊都整合到這個app裏。但是要知道美團之前就是美團外賣,後來的這些功能都是整合進來的。分屬於不同的獨立引用或者app中,肯定有自己獨立的登陸。這些應用的不在一個模塊甚至項目中。但是整合到美團app後,只要在app這層登陸後,這五大模塊的登陸態就變成已登錄狀態,從而訪問裏面的信息不會應爲再次登陸而被登陸攔截。一次登陸後,在這五個功能模塊中都是已經登陸,便是統一登陸!
統一登陸絕不是美團app登陸後,在電腦網頁版就也是登錄態,這是不可能實現的能力。統一登陸是一種整合能力的表現。
2.實現統一登陸的技術選擇
美團外賣的統一登陸或許有別的實現統一登陸的方式。上面只是以app的首頁講解何爲統一登陸。這裏的實現是我們公司的具體實現,和美團無關。
實現對請求的攔截處理有兩種方式: implements javax.servlet.Filter
和 implements org.springframework.web.servlet.HandlerInterceptor
HandlerInterceptor在方法的preHandle,postHandle,afterCompletion實現攔截邏輯。Filter是在方法 doFilter()中實現攔截邏輯。
但是這裏要有個清楚的認識Spring的Interceptor與Servlet的Filter區別點:
Filter作用於servlet容器,Interceptor作用於spring容器。一個請求過來經過二者的先後順序是這樣的。
Filter前處理 --> Interceptor前處理 --> action --> Interceptor後處理 --> Filter後處理
- 如果只是單純的登錄校驗,將校驗的代碼放在Filter裏面會更好,因爲這樣請求都不會到Spring裏面就已經被Servlet拒絕了,這樣可以更加好的保證Spring的安全。
- 如果項目需要根據權限去攔截,需要根據用戶信息去查詢數據庫表。這就不可避免的要使用Spring的組件了,這種情況下就使用Interceptor處理好一些。
- 但是對於springboot項目,通過對filter註解
@Component
,即org.springframework.stereotype.Component
來讓spring來管理。也是可以在filter中實現注具體service層的。
3.項目中的技術實現思路
對於springcloud項目的統一登陸大都放在zuul網關層實現,對登陸進行校驗,攔截。對於zuul下的項目都需要登陸校驗的項目來說是沒有問題。但是我們的項目的登陸需求並不是都需要。有寫還不需要登陸。大致是這樣的。項目一,二和訂單模塊需要登陸,但是三方支付模塊和open模塊是不需要登陸的。而這些都在zuul網關的治理下。如果在zuul網關加上登陸校驗,則不需要登陸的模塊就會請求攔截,顯示未登錄進不了服務模塊。
所以我們採用在各自模塊上通過filter實現各自的登陸校驗。而登陸要滿足各個項目實現統一登陸。
技術實現簡單說就是:jwt實現token回傳給客戶端,客戶端的每次請求都帶着token給後臺。token+filter實現登陸態校驗。token+session實現一次解析用戶信息,項目層面更簡單獲取用戶信息。
4.技術的具體實現
用戶註冊登陸接口(filter放行)返回生成的token,而後所有請求會被filter攔截進行token校驗。
- 4.1 引入jwt的pom依賴
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
- 4.2 通過jwt生成token的工具類
public class Constant {
public static final String TOKEN = "token";
public static final String EXPIRE_TIME = "expireTime";
public static final String USER_ID = "userId";
public static final String LOGIN_NAME = "loginName";
public static final String USER_NAME = "userName";
public static final String MOBILE = "mobile";
public static final String IP_ADDRESS = "ipAddress";
}
import com.server.constant.Constant;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@SuppressWarnings("Duplicates")
@Component
public class TokenComponent {
private Logger log =LoggerFactory.getLogger(TokenComponent.class);
public static final String CLAIM_KEY_USER = "userId";
public static final String CLAIM_KEY_CREATED = "created";
public static final String CLAIM_KEY_MOBILE = "mobile";
public static final String CLAIM_KEY_USERNAME = "userName";
public static final String CLAIM_KEY_LOGINNAME = "loginName";
private String secret = "secret";
/**
* 7 days
*/
private Long defaultExpiration = 604800L;
/**
* 生成token
* @param mobile 手機
* @param userId 用戶ID
* @param userName 用戶名稱
* @param expiration 過期時間
* @return TOKEN
*/
public Map<String, String> generateToken(String userId, String mobile, String userName,String loginName, Long expiration) {
Map<String, Object> claims = new HashMap<>(5);
claims.put(CLAIM_KEY_USER, userId);
claims.put(CLAIM_KEY_MOBILE, mobile);
claims.put(CLAIM_KEY_CREATED, new Date());
claims.put(CLAIM_KEY_USERNAME, userName);
claims.put(CLAIM_KEY_LOGINNAME, loginName);
Date expirationDate;
if (expiration == null) {
expirationDate = new Date(System.currentTimeMillis() + defaultExpiration * 1000);
} else {
expirationDate = new Date(System.currentTimeMillis() + expiration * 1000);
}
String token = Jwts.builder()
.setClaims(claims)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
Map<String, String> result = new HashMap<>();
result.put(Constant.TOKEN,token);
result.put(Constant.EXPIRE_TIME,expirationDate.getTime()+"");
return result;
}
/**
* 解析token
* @param token
* @return 用戶信息的Map集合:Claims
*/
public Claims getClaimsFromToken(String token) {
log.info("getShopId request header Authorization={}", token);
try {
return Jwts
.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
log.error("商家未登錄:{}", e);
throw new JwtException(e.getMessage());
}
}
/**
* 判斷token是不失效
* @param token
* @return 是否失效
*/
public Boolean isTokenExpire(String token) {
try {
Claims claims = getClaimsFromToken(token);
Date expire = claims.getExpiration();
return !expire.before(new Date());
} catch (Exception e) {
log.error("商家未登錄:{}", e);
throw new JwtException(e.getMessage());
}
}
}
import com.service.token.TokenComponent;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class SessionUtil {
public static Map<String,String> saveSession(RedisService redisService, HttpServletRequest request, String userId, String
loginName, String
userName, String mobile, TokenComponent tokenComponent){
Map<String,String> tokenAndExpireTime = tokenComponent.generateToken(userId,mobile,userName,loginName,30*60L);
HttpSession session = request.getSession();
String token = tokenAndExpireTime.get(Constant.TOKEN);
session.setAttribute(Constant.TOKEN, token);
session.setAttribute(Constant.USER_ID, userId);
session.setAttribute(Constant.LOGIN_NAME, loginName);
session.setAttribute(Constant.USER_NAME, userName);
session.setAttribute(Constant.MOBILE, mobile);
session.setAttribute(Constant.IP_ADDRESS, RequestUtil.getIpAddress(request));
// 刷新session有效時間30分鐘
session.setMaxInactiveInterval(604800);
return tokenAndExpireTime;
}
}
- 4.3 登錄註冊接口中生成token,同時將用戶信息保存session中。
SessionUtil.saveSession在上面這個代碼塊中。生成token等返回給前端。
Map<String, String> tokenAndExpireTime = SessionUtil.saveSession(redisService, request, loginVo.getId(),
loginVo.getLoginName(),
loginVo.getUserName(), mobile, tokenComponent);
result.put(ID, loginVo.getId());
result.put(TOKEN, tokenAndExpireTime.get(Constant.TOKEN));
result.put(EXPIRE_TIME,tokenAndExpireTime.get(Constant.EXPIRE_TIME));
LOGGER.info("loginVo.getId():" + loginVo.getId());
return ResponseJson.success(RESPONSE_CODE_SUCCESS, result);
- 4.4 前端請求登錄以後的接口
在filter爲放開,則要進行登錄校驗。校驗通過則將用戶信息寫入session。
@Component
@RefreshScope
@Data
public class YmlConfig {
/**
* @Description filter
*/
@Value("${filter}")
private String filter;
}
import com.alibaba.fastjson.JSONObject;
import com.github.pagehelper.util.StringUtil;
import com.ly.mt.mall.config.YmlConfig;
import com.ly.mt.mall.service.redis.service.RedisService;
import com.ly.mt.mall.service.token.TokenComponent;
import com.ly.mt.mall.util.Constant;
import com.ly.mt.mall.util.FilterUtil;
import com.ly.mt.mall.util.RequestUtil;
import com.ly.mt.mall.vo.ResponseJson;
import io.jsonwebtoken.Claims;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import static com.ly.mt.mall.service.redis.RedisKey.REDIS_TOKEN_LOGIN_MALL_H5;
import static com.ly.mt.mall.vo.ResponseCode.RESPONSE_CODE_NOT_LOGIN;
@Component
public class MyFilter implements Filter {
private final static Logger LOGGER = LoggerFactory.getLogger(MyFilter.class);
@Autowired
private YmlConfig yml;
@Autowired
private TokenComponent tokenComponent;
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void destroy() {
}
@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse,
FilterChain filterChain) {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
try {
HttpSession session = request.getSession();
LOGGER.info("1請求sessionId={},url={}", session.getId(), request.getRequestURL());
String uri = request.getRequestURI();
// 放行地址
if (FilterUtil.checkFilter(uri, yml.getFilter())) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}
String jwtToken = request.getHeader(Constant.TOKEN);
if (StringUtil.isNotEmpty(jwtToken)) {
Claims claims = tokenComponent.getClaimsFromToken(jwtToken);
LOGGER.info("===================================userCache:" + claims.toString());
if (StringUtil.isEmpty(claims.toString())) {
LOGGER.info("用戶名或token爲空,未登錄攔截");
return;
}
session.setAttribute(Constant.USER_ID, claims.get(TokenComponent.CLAIM_KEY_USER));
session.setAttribute(Constant.USER_NAME, claims.get(TokenComponent.CLAIM_KEY_USERNAME));
session.setAttribute(Constant.LOGIN_NAME, claims.get(TokenComponent.CLAIM_KEY_LOGINNAME));
session.setAttribute(Constant.MOBILE, claims.get(TokenComponent.CLAIM_KEY_MOBILE));
session.setAttribute(Constant.TOKEN, jwtToken);
session.setAttribute(Constant.IP_ADDRESS, RequestUtil.getIpAddress(request));
}
// 未登錄攔截
String loginName = String.valueOf(session.getAttribute(Constant.LOGIN_NAME));
String token = String.valueOf(session.getAttribute(Constant.TOKEN));
if (StringUtil.isEmpty(loginName) || StringUtil.isEmpty(token)) {
LOGGER.info("用戶名或token爲空,未登錄攔截");
notLogin(loginName, session, request, response);
return;
}
filterChain.doFilter(servletRequest, servletResponse);
} catch (Exception e) {
LOGGER.error("filter執行異常:", e);
return;
}
}
/**
* @Description 未登錄攔截
*/
private void notLogin(String loginName, HttpSession session, HttpServletRequest request, HttpServletResponse response) {
LOGGER.info("!!!!!!!!!!未登錄攔截,銷燬session,清空cookie");
// 銷燬session
session.invalidate();
// 刪除cookie token
Cookie tokenCookie = getTokenCookie(request);
if (null != tokenCookie) {
tokenCookie.setMaxAge(0);
tokenCookie.setPath("/");
response.addCookie(tokenCookie);
}
}
/**
* @Description 獲取返回前端的token cookie
*/
private Cookie getTokenCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (null == cookies) {
return null;
}
for (Cookie cookie : cookies) {
if (Constant.TOKEN.equals(cookie.getName())) {
return cookie;
}
}
return null;
}
}
4.6 如果登錄成功。session中時保存一份用戶信息的。通過封裝用戶信息到基本service類中,然後新定義的類繼承這個類,即可隨時取用用戶基本信息。
import com.util.Constant;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
/**
* 模塊公共接口,處理本模塊公共方法
*
* @author zhanglifeng
*/
@Service
public class BaseServiceImpl implements BaseService {
@Override
public String getLoginUserId() {
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
Assert.notNull(sra, "sra must not be null");
HttpServletRequest request = sra.getRequest();
HttpSession session = request.getSession();
return String.valueOf(session.getAttribute("userId"));
}
@Override
public String getLoginUserName() {
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
Assert.notNull(sra, "sra must not be null");
HttpServletRequest request = sra.getRequest();
HttpSession session = request.getSession();
return String.valueOf(session.getAttribute("userName"));
}
@Override
public String getLoginUserIp() {
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
Assert.notNull(sra, "sra must not be null");
HttpServletRequest request = sra.getRequest();
HttpSession session = request.getSession();
return String.valueOf(session.getAttribute("ipAddress"));
}
@Override
public String getLoginUserMobile() {
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
Assert.notNull(sra, "sra must not be null");
HttpServletRequest request = sra.getRequest();
HttpSession session = request.getSession();
return String.valueOf(session.getAttribute(Constant.MOBILE));
}
}
以上便是其中一個項目的登錄配置。其中filter中放開攔截的部分根據模塊對應的業務,會靈活配置相應的放開接口地址。都是通過前臺獲取的token中拿到用戶登錄信息。並存放到session中