首先呢就是需求:
1、賬號、密碼進行第一次登入,獲得token,之後的每次請求都在請求頭裡加上這個token就不用帶賬號、密碼或是session了。
2、使用者有兩種型別,具體表現在資料庫中存使用者資訊時是分開兩張表進行儲存的。
為什麼會分開存兩張表呢,這個設計的時候是先設計的表結構,有分開的必要所以就分開存了,也沒有想過之後Security 這塊需要進行一些修改,但是分開存就分開存吧,Security 這塊也不是很複雜。
maven就是這兩:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency>
然後直接說程式碼吧,首先呢是實現dao層,這一層就不貼程式碼了,反正就是能根據使用者名稱能返回使用者資訊就好了。
所以其實第一步是實現自己的安全模型:
這一步是實現UserDetails這個介面,其中我額外新增了使用者型別、使用者Id。其他的都是UserDetails介面必須實現的。
/** * 安全使用者模型 * @author xuwang * Created on 2019/05/28 20:07 */ public class XWUserDetails implements UserDetails { //使用者型別code public final static String USER_TYPE_CODE = "1"; //管理員型別code public final static String MANAGER_TYPE_CODE = "2"; //使用者id private Integer userId; //使用者名稱 private String username; //密碼 private String password; //使用者型別 private String userType; //使用者角色表 private Collection<? extends GrantedAuthority> authorities; public XWUserDetails(Integer userId,String username, String password, String userType, Collection<? extends GrantedAuthority> authorities){ this.userId = userId; this.username = username; this.password = password; this.userType = userType; this.authorities = authorities; } /** * 獲取許可權列表 * @return Collection */ @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } /** * 獲取使用者Id * @return String */ public Integer getUserId() { return userId; } /** * 獲取使用者型別 * @return String */ public String getUserType() { return userType; } /** * 獲取密碼 * @return String */ @Override public String getPassword() { return password; } /** * 獲取使用者名稱 * @return String */ @Override public String getUsername() { return username; } /** * 賬號是否未過期 * @return boolean */ @Override public boolean isAccountNonExpired() { return true; } /** * 賬號是否未鎖定 * @return boolean */ @Override public boolean isAccountNonLocked() { return true; } /** * 憑證是否未過期 * @return boolean */ @Override public boolean isCredentialsNonExpired() { return true; } /** * 賬號是否已啟用 * @return boolean */ @Override public boolean isEnabled() { return true; }
第二步是實現兩個UserDetailsService因為要從兩張表裡進行查詢,所以我就實現了兩個UserDetailsService
這一步呢,注入了dao層的東西,從資料庫中查詢出使用者資訊,構建XWUserDetails並返回。
/** * Manager專用的UserDetailsService * @author xuwang * Created on 2019/06/01 15:58 */ @Service("managerDetailsService") public class ManagerDetailsServiceImpl implements UserDetailsService { @Resource ScManagerMapper_Security scManagerMapper_security; @Resource ScRoleMapper_Security scRole_Mapper_security; /** * 根據使用者名稱從資料庫中獲取XWUserDetails * @param username * @return UserDetails * @throws UsernameNotFoundException */ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //獲取使用者資訊 ScManager user = scManagerMapper_security.findByUsername(username); //獲取角色列表 List<String> roles = scRole_Mapper_security.findByUsername(username); if (user == null) { throw new UsernameNotFoundException(String.format("No user found with username '%s'.", username)); } else { return new XWUserDetails(user.getId(),user.getManagerName(), user.getLoginPass(),XWUserDetails.MANAGER_TYPE_CODE, roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList())); } } }
/** * User專用的UserDetailsService * @author xuwang * Created on 2019/06/01 15:58 */ @Service("userDetailsService") public class UserDetailsServiceImpl implements UserDetailsService { @Resource ScUserMapper_Security userMapper_security; @Resource ScRoleMapper_Security scRole_Mapper_security; /** * 根據使用者名稱從資料庫中獲取XWUserDetails * @param username * @return UserDetails * @throws UsernameNotFoundException */ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //獲取使用者資訊 ScUser user = userMapper_security.findByUsername(username); //獲取角色列表 List<String> roles = scRole_Mapper_security.findByUsername(username); if (user == null) { throw new UsernameNotFoundException(String.format("No user found with username '%s'.", username)); } else { return new XWUserDetails(user.getId(),user.getName(),user.getPassword(), XWUserDetails.MANAGER_TYPE_CODE, roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList())); } } }
第三步,實現兩個UsernamePasswordAuthenticationToken
這一步的話,其實單看不知道為什麼實現兩個類,但是註釋裡面我寫了,然後真正的為什麼,整體流程,到最後說吧。
/** * manager專用的UsernamePasswordAuthenticationToken * AuthenticationManager會遍歷使用Provider的supports()方法,判斷AuthenticationToken是不是自己想要的 * @author xuwang * Created on 2019/06/01 15:58 */ public class ManagerAuthenticationToken extends UsernamePasswordAuthenticationToken { public ManagerAuthenticationToken(Object principal, Object credentials) { super(principal, credentials); } }
/** * User專用的UsernamePasswordAuthenticationToken * AuthenticationManager會遍歷使用Provider的supports()方法,判斷AuthenticationToken是不是自己想要的 * @author xuwang * Created on 2019/06/01 15:58 */ public class UserAuthenticationToken extends UsernamePasswordAuthenticationToken { public UserAuthenticationToken(Object principal, Object credentials){ super(principal,credentials); } }
第四步,實現兩個AuthenticationProvider
這個地方用到了上面的兩個類,重點是supports()方法,這個方法是用來校驗傳進來的UsernamePasswordAuthenticationToken的,反正就代表著這個ManagerAuthenticationProvider就只適用於ManagerAuthenticationToken,另一個同理,具體也是最後說吧。
/** * Manager專用的AuthenticationProvider * 選擇實現DaoAuthenticationProvider是因為比較方便且能用 * @author xuwang * Created on 2019/06/01 15:58 */ public class ManagerAuthenticationProvider extends DaoAuthenticationProvider { /** * 初始化 將使用Manager專用的userDetailsService * @param encoder * @param userDetailsService */ public ManagerAuthenticationProvider(PasswordEncoder encoder, UserDetailsService userDetailsService){ setPasswordEncoder(encoder); setUserDetailsService(userDetailsService); } @Override public void setPasswordEncoder(PasswordEncoder passwordEncoder) { super.setPasswordEncoder(passwordEncoder); } @Override public void setUserDetailsPasswordService(UserDetailsPasswordService userDetailsPasswordService) { super.setUserDetailsPasswordService(userDetailsPasswordService); } /** * 判斷只有傳入ManagerAuthenticationToken的時候才使用這個Provider * supports會在AuthenticationManager層被呼叫 * @param authentication * @return */ public boolean supports(Class<?> authentication) { return ManagerAuthenticationToken.class.isAssignableFrom(authentication); } }
/** * 實現User專用的AuthenticationProvider * 選擇實現DaoAuthenticationProvider是因為比較方便且能用 * @author xuwang * Created on 2019/06/01 15:58 */ public class UserAuthenticationProvider extends DaoAuthenticationProvider { /** * 初始化 將使用User專用的userDetailsService * @param encoder * @param userDetailsService */ public UserAuthenticationProvider(PasswordEncoder encoder, UserDetailsService userDetailsService){ setPasswordEncoder(encoder); setUserDetailsService(userDetailsService); } @Override public void setPasswordEncoder(PasswordEncoder passwordEncoder) { super.setPasswordEncoder(passwordEncoder); } @Override public void setUserDetailsPasswordService(UserDetailsPasswordService userDetailsPasswordService) { super.setUserDetailsPasswordService(userDetailsPasswordService); } /** * 判斷只有傳入UserAuthenticationToken的時候才使用這個Provider * supports會在AuthenticationManager層被呼叫 * @param authentication * @return */ public boolean supports(Class<?> authentication) { return UserAuthenticationToken.class.isAssignableFrom(authentication); } }
第五步就是繼承實現這個WebSecurityConfigurerAdapter
這一步呢,主要是將上面兩個AuthenticationProvider加入到AuthenticationManager中,並向Spring中注入這個AuthenticationManager供Service在校驗賬號密碼時使用。
同時還注入了一個PasswordEncoder,也是同樣供Service層使用,反正就是其他地方能用就是了,就不用new了。
然後是configure方法,這個裡面,具體就是Security 的配置了,為什麼怎麼寫我就不說了,反正我這裡實現了url的配置、Session的關閉、Filter的設定、設定驗證失敗許可權不足自定義返回值。
其中Filter、和驗證失敗許可權不足再看後面的程式碼吧,我也會貼上的。
關於AuthenticationManager,就是先用加密工具、和之前實現的UserDetailsService 構造兩個DaoAuthenticationProvider,然後在configureGlobal()方法中新增這兩個DaoAuthenticationProvider,最後authenticationManagerBean()方法進行注入。
/** * Security 配置 * @author xuwang * Created on 2019/06/01 15:58 */ @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class XWSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired @Qualifier("userDetailsService") private UserDetailsService userDetailsService; @Autowired @Qualifier("managerDetailsService") private UserDetailsService managerDetailsService; @Resource private XWAuthenticationTokenFilter xwAuthenticationTokenFilter; @Resource private EntryPointUnauthorizedHandler entryPointUnauthorizedHandler; @Resource private RestAccessDeniedHandler restAccessDeniedHandler; /** * 注入UserAuthenticationProvider * @return */ @Bean("UserAuthenticationProvider") DaoAuthenticationProvider daoUserAuthenticationProvider(){ return new UserAuthenticationProvider(encoder(), userDetailsService); } /** * 注入ManagerAuthenticationProvider * @return */ @Bean("ManagerAuthenticationProvider") DaoAuthenticationProvider daoMangerAuthenticationProvider(){ return new ManagerAuthenticationProvider(encoder(), managerDetailsService); } /** * 向AuthenticationManager新增Provider * @return */ @Autowired public void configureGlobal(AuthenticationManagerBuilder auth){ auth.authenticationProvider(daoUserAuthenticationProvider()); auth.authenticationProvider(daoMangerAuthenticationProvider()); } @Autowired public void configureAuthentication(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception { authenticationManagerBuilder.userDetailsService(this.userDetailsService).passwordEncoder(passwordEncoder); } /** * 注入AuthenticationManager * @return */ @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } /** * 注入PasswordEncoder * @return */ @Bean public PasswordEncoder encoder() { PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); return encoder; } /** * 具體Security 配置 * @return */ @Override protected void configure(HttpSecurity http) throws Exception { http. csrf().disable().//預設開啟,這裡先顯式關閉csrf sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //Spring Security永遠不會建立HttpSession,它不會使用HttpSession來獲取SecurityContext .and() .authorizeRequests() .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() //任何使用者任意方法可以訪問/** .antMatchers("/base/login").permitAll() //任何使用者可以訪問/user/** .anyRequest().authenticated() //任何沒有匹配上的其他的url請求,只需要使用者被驗證 .and() .headers().cacheControl(); http.addFilterBefore(xwAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); http.exceptionHandling().authenticationEntryPoint(entryPointUnauthorizedHandler).accessDeniedHandler(restAccessDeniedHandler); } }
然後是上面的兩個Handler
這個很簡單,就是實現AccessDeniedHandler和AuthenticationEntryPoint就是了。
/** * 身份驗證失敗自定401返回值 * * @author xuwang * Created on 2019/05/29 16:10. */ @Component public class EntryPointUnauthorizedHandler implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException { httpServletResponse.setHeader("Access-Control-Allow-Origin", "*"); httpServletResponse.setStatus(401); } }
/** * 許可權不足自定403返回值 * * @author xuwang * Created on 2019/05/29 16:10. */ @Component public class RestAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException { httpServletResponse.setHeader("Access-Control-Allow-Origin", "*"); httpServletResponse.setStatus(403); } }
再然後就是JWT這一塊了。
首先是一個Token的工具類,裡面有些什麼東西直接看註釋就好了。
/** * JWT工具類 * * @author xuwang * Created on 2019/05/28 20:16. */ @Component public class XWTokenUtil implements Serializable { /** * 金鑰 */ private final String secret = "11111111"; /** * 從資料宣告生成令牌 * * @param claims 資料宣告 * @return 令牌 */ private String generateToken(Map<String, Object> claims) { //有效時間 Date expirationDate = new Date(System.currentTimeMillis() + 2592000L * 1000); return Jwts.builder().setClaims(claims).setExpiration(expirationDate).signWith(SignatureAlgorithm.HS512, secret).compact(); } /** * 從令牌中獲取資料宣告 * * @param token 令牌 * @return 資料宣告 */ private Claims getClaimsFromToken(String token) { Claims claims; try { claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } catch (Exception e) { claims = null; } return claims; } /** * 生成令牌 * * @param userDetails 使用者 * @return 令牌 */ public String generateToken(UserDetails userDetails) { Map<String, Object> claims = new HashMap<>(2); claims.put("sub", userDetails.getUsername()); claims.put("userId", ((XWUserDetails)userDetails).getUserId()); claims.put("userType", ((XWUserDetails)userDetails).getUserType()); claims.put("created", new Date()); return generateToken(claims); } /** * 從令牌中獲取使用者名稱 * * @param token 令牌 * @return 使用者名稱 */ public String getUsernameFromToken(String token) { String username; try { Claims claims = getClaimsFromToken(token); username = claims.getSubject(); } catch (Exception e) { username = null; } return username; } /** * 從令牌中獲取使用者型別 * * @param token 令牌 * @return 使用者型別 */ public String getUserTypeFromToken(String token) { String userType; try { Claims claims = getClaimsFromToken(token); userType = (String) claims.get("userType"); } catch (Exception e) { userType = null; } return userType; } /** * 從令牌中獲取使用者Id * * @param token 令牌 * @return 使用者Id */ public Integer getUserIdFromToken(String token) { Integer userId; try { Claims claims = getClaimsFromToken(token); userId = (Integer) claims.get("userId"); } catch (Exception e) { userId = null; } return userId; } /** * 判斷令牌是否過期 * * @param token 令牌 * @return 是否過期 */ public Boolean isTokenExpired(String token) { try { Claims claims = getClaimsFromToken(token); Date expiration = claims.getExpiration(); return expiration.before(new Date()); } catch (Exception e) { return false; } } /** * 重新整理令牌 * * @param token 原令牌 * @return 新令牌 */ public String refreshToken(String token) { String refreshedToken; try { Claims claims = getClaimsFromToken(token); claims.put("created", new Date()); refreshedToken = generateToken(claims); } catch (Exception e) { refreshedToken = null; } return refreshedToken; } /** * 驗證令牌 * * @param token 令牌 * @param userDetails 使用者 * @return 是否有效 */ public Boolean validateToken(String token, UserDetails userDetails) { XWUserDetails user = (xwUserDetails) userDetails; String username = getUsernameFromToken(token); return (username.equals(user.getUsername()) && !isTokenExpired(token)); } }
然後是Filter
這個Filter大家都知道請求發過來,會先進行這個Filter裡面的方法,這裡的邏輯也很簡單,從Token中拿到身份資訊,並進行驗證,驗證這裡我寫得比簡單,可以再加邏輯。
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
重點是這三行,這三行是什麼意思呢,前面說了需求是第一次登入驗證成功,以後發請求使用Token就好了,這三行之前的邏輯是在校驗Token,從Token中獲取使用者資訊,但系統中進行許可權管理的是Spring Security,並沒有使用Spring Security 進行驗證啊,
所以需要做的就是這三行,這三行中的SecurityContextHolder就是:SecurityContextHolder是用來儲存SecurityContext的。SecurityContext中含有當前正在訪問系統的使用者的詳細資訊,
實際就是使用使用者資訊構建authentication放到SecurityContextHolder就等於使用者已經登入了,就不用再校驗密碼什麼的了。
/** * JWT Filter * * @author xuwang * Created on 2019/05/29 16:10. */ @Component public class XWAuthenticationTokenFilter extends OncePerRequestFilter { @Resource ManagerDetailsServiceImpl managerDetailsService; @Resource UserDetailsServiceImpl userDetailsService; @Resource private XWTokenUtil xwTokenUtil; /** * 獲取驗證token中的身份資訊 * @author xuwang */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { //從請求頭中獲取token String authHeader = request.getHeader("Authorization"); //token字首 String tokenHead = "Bearer "; if (authHeader != null && authHeader.startsWith(tokenHead)) { //去掉token字首 String authToken = authHeader.substring(tokenHead.length()); //從token中獲取使用者名稱 String username = XWTokenUtil.getUsernameFromToken(authToken); String userType = XWTokenUtil.getUserTypeFromToken(authToken); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = null; //根據從token中獲取使用者名稱從資料庫中獲取一個userDetails if(userType.equals(XWUserDetails.USER_TYPE_CODE)){ //普通使用者 userDetails = userDetailsService.loadUserByUsername(username); }else if(userType.equals(XWUserDetails.MANAGER_TYPE_CODE)){ //管理員 userDetails = managerDetailsService.loadUserByUsername(username); } if (xwTokenUtil.validateToken(authToken, userDetails)) { //token中的使用者資訊和資料庫中的使用者資訊對比成功後將使用者資訊加入SecurityContextHolder相當於登陸 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } chain.doFilter(request, response); } }
然後是使用者登入的介面了,我直接貼Service層的程式碼吧
/** * @ClassName: loginServiceImpl * @ClassNameExplain: * @Description: 業務層實現類 * @author xuwang * @date 2019-05-31 16:15:46 */ @Service public class LoginServiceImpl implements ILoginService { static final Logger logger = LoggerFactory.getLogger(LoginServiceImpl.class); @Resource private AuthenticationManager authenticationManager; @Autowired @Qualifier("userDetailsService") private UserDetailsService userDetailsService; @Autowired @Qualifier("managerDetailsService") private UserDetailsService managerDetailsService; @Resource private XWTokenUtil xwTokenUtil; @Override public LoginVO login(LoginIO loginIO) throws Exception { //不同的使用者型別使用不同的登陸方式 String token = ""; UserDetails userDetails = null; if(loginIO.getType().equals(XWUserDetails.USER_TYPE_CODE)){ //登入 login(new UserAuthenticationToken(loginIO.getUserName(), loginIO.getPassword())); userDetails = userDetailsService.loadUserByUsername(loginIO.getUserName()); token = xwTokenUtil.generateToken(userDetails); logger.info("user[{}]登陸成功",loginIO.getUserName()); }else if(loginIO.getType().equals(XWUserDetails.MANAGER_TYPE_CODE)){ login(new ManagerAuthenticationToken(loginIO.getUserName(), loginIO.getPassword())); userDetails = managerDetailsService.loadUserByUsername(loginIO.getUserName()); token = xwUtil.generateToken(userDetails); logger.info("manager[{}]登陸成功",loginIO.getUserName()); }else { logger.error("type[{}]引數錯誤",loginIO.getType()); //type引數錯誤 throw new BusinessException(ExceptionConstants.PARAM_INVALID_CODE, ExceptionConstants.PARAM_INVALID_MSG); } LoginVO loginVO = new LoginVO(); loginVO.setToken(token); loginVO.setUserId(((XWUserDetails)userDetails).getUserId()); return loginVO == null ? new LoginVO() : loginVO; } /** * 校驗賬號密碼並進行登陸 * @param upToken */ private void login(UsernamePasswordAuthenticationToken upToken){ //驗證 Authentication authentication = authenticationManager.authenticate(upToken); //將使用者資訊儲存到SecurityContextHolder=登陸 SecurityContextHolder.getContext().setAuthentication(authentication); } }
這個Service解釋一下就是:
loginIO能接收到使用者資訊:賬號UserName、密碼Password、型別Type之類的,然後使用AuthenticationManager 進行校驗,再使用SecurityContextHolder進行登入操作(上面解釋過了),最後返回Token(xwTokenUtil工具類生成的)和使用者Id。
最後解釋一下其中的流程,已經我為什麼這麼去實現吧。
從Service中我們可以看到,登入時使用的是先使用賬號密碼構建了一個UsernamePasswordAuthenticationToken,我這裡構建的是UserAuthenticationToken、ManagerAuthenticationToken,不過影響不大,都是它的子類,AuthenticationManager的authenticate將接受一個UsernamePasswordAuthenticationToken來進行驗證,最後才登入。
上面的相當於Security的登入使用流程。
然後解釋一下前面的那些所有的疑惑,在Service中使用AuthenticationManager的authenticate()方法進行校驗的時候,實際上是會把UsernamePasswordAuthenticationToken傳遞給Provider進行校驗的,Provider裡呢又讓Service去校驗的。這是類和類之間的關係,然後還有實際程式碼關係是,AuthenticationManager中會有一個Provider列表,進行校驗的時候會遍歷使用每一個Provider的supports()方法,這個supports()方法將校驗傳進來的UsernamePasswordAuthenticationToken是自己想要的UsernamePasswordAuthenticationToken嗎,如果是的話就使用這個Provider進行校驗。所以我實現了UserAuthenticationToken、ManagerAuthenticationToken,還實現了ManagerAuthenticationProvider、UserAuthenticationProvider以及其中的supports()方法,這樣authenticationManager.authenticate(new UserAuthenticationToken)就會使用UserAuthenticationProvider中的UserDetailsServiceImpl去校驗了。ManagerAuthenticationToken同理。
上面的我自己是覺得寫得是比較清晰了。如果實在是看不明白,或者其實是我還是寫得太爛了,可以自己跟一下程式碼,就從AuthenticationManager.authenticate()方法跟進去就好了,
具體跟程式碼的時候要注意,AuthenticationManager的預設實現是ProviderManager,所以其實看到的是ProviderManager的authenticate()方法
圖中:
1. 獲取到Authentication的類資訊
2. 得到Provider列表的迭代器
3.進行遍歷
4.呼叫Provider的supports()方法
所以我重寫了兩個provider和其中supports()方法,和兩個AuthenticationToken。
然後其實這個東西並不算是太複雜,自己去用和學習的時候,最好還是先實現,然後在慢慢跟程式碼,去猜去思考其中的流程,就好了。
最後是為什麼要這樣去寫程式碼、去注入、用這個方式進行加密、以及token中存放的資訊、loginIo得設定等等的,這些都是可以任意更改的,無需糾結太多,根據根據個人習慣和當時的業務改就好了,至於到底怎樣才是最好的,我也沒太認真的去思考過,畢竟加班的時候只能先實現功能了,至於為什麼在寫這個部落格的時候還不去思考的原因就是。。。因為這些並不是重點