diff --git a/pom.xml b/pom.xml index b16971f..917d01c 100644 --- a/pom.xml +++ b/pom.xml @@ -51,12 +51,6 @@ lombok true - - - io.jsonwebtoken - jjwt - 0.9.1 - org.springframework.boot diff --git a/src/main/java/cn/teammodel/DeploymentTestApplication.java b/src/main/java/cn/teammodel/TeamModelExtensionApplication.java similarity index 65% rename from src/main/java/cn/teammodel/DeploymentTestApplication.java rename to src/main/java/cn/teammodel/TeamModelExtensionApplication.java index f1a653b..b25bb74 100644 --- a/src/main/java/cn/teammodel/DeploymentTestApplication.java +++ b/src/main/java/cn/teammodel/TeamModelExtensionApplication.java @@ -4,10 +4,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -public class DeploymentTestApplication { +public class TeamModelExtensionApplication { public static void main(String[] args) { - SpringApplication.run(DeploymentTestApplication.class, args); + SpringApplication.run(TeamModelExtensionApplication.class, args); } } diff --git a/src/main/java/cn/teammodel/config/exception/ServiceException.java b/src/main/java/cn/teammodel/config/exception/ServiceException.java new file mode 100644 index 0000000..ee5a2e1 --- /dev/null +++ b/src/main/java/cn/teammodel/config/exception/ServiceException.java @@ -0,0 +1,72 @@ +package cn.teammodel.config.exception; + +/** + * 业务异常 + * + * @author winter + */ +public final class ServiceException extends RuntimeException +{ + private static final long serialVersionUID = 1L; + + /** + * 错误码 + */ + private Integer code; + + /** + * 错误提示 + */ + private String message; + + /** + * 错误明细,内部调试错误 + */ + private String detailMessage; + + /** + * 空构造方法,避免反序列化问题 + */ + public ServiceException() + { + } + + public ServiceException(String message) + { + this.message = message; + } + + public ServiceException(String message, Integer code) + { + this.message = message; + this.code = code; + } + + public String getDetailMessage() + { + return detailMessage; + } + + @Override + public String getMessage() + { + return message; + } + + public Integer getCode() + { + return code; + } + + public ServiceException setMessage(String message) + { + this.message = message; + return this; + } + + public ServiceException setDetailMessage(String detailMessage) + { + this.detailMessage = detailMessage; + return this; + } +} \ No newline at end of file diff --git a/src/main/java/cn/teammodel/controller/HelloController.java b/src/main/java/cn/teammodel/controller/HelloController.java index 5740065..1a6ca8b 100644 --- a/src/main/java/cn/teammodel/controller/HelloController.java +++ b/src/main/java/cn/teammodel/controller/HelloController.java @@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.RestController; public class HelloController { @GetMapping("hello") - @PreAuthorize("hasAuthority('IES')") + @PreAuthorize("@ss.hasRole('admin')") public R hello() { System.out.println(SecurityContextHolder.getContext().getAuthentication()); return new R(200, "success","hello world"); diff --git a/src/main/java/cn/teammodel/model/entity/TmdUserDetail.java b/src/main/java/cn/teammodel/model/entity/TmdUserDetail.java new file mode 100644 index 0000000..bebb961 --- /dev/null +++ b/src/main/java/cn/teammodel/model/entity/TmdUserDetail.java @@ -0,0 +1,50 @@ +package cn.teammodel.model.entity; + +import lombok.Data; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; + +/** + * @author winter + * @create 2023-11-09 15:28 + */ +@Data +public class TmdUserDetail implements UserDetails { + private User user; + @Override + public Collection getAuthorities() { + return null; + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return null; + } + + @Override + public boolean isAccountNonExpired() { + return false; + } + + @Override + public boolean isAccountNonLocked() { + return false; + } + + @Override + public boolean isCredentialsNonExpired() { + return false; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/cn/teammodel/model/entity/User.java b/src/main/java/cn/teammodel/model/entity/User.java new file mode 100644 index 0000000..a5c4ed5 --- /dev/null +++ b/src/main/java/cn/teammodel/model/entity/User.java @@ -0,0 +1,24 @@ +package cn.teammodel.model.entity; + +import lombok.Data; +import lombok.ToString; + +import java.util.Set; + +/** + * @author winter + * @create 2023-11-09 15:43 + */ +@Data +@ToString +public class User { + private String id; + private String name; + private String picture; + private String standard; + private String scope; + private String website; + private String area; + private Set roles; + private Set permissions; +} diff --git a/src/main/java/cn/teammodel/security/SecurityConfiguration.java b/src/main/java/cn/teammodel/security/SecurityConfiguration.java index 32fb123..a5b3b60 100644 --- a/src/main/java/cn/teammodel/security/SecurityConfiguration.java +++ b/src/main/java/cn/teammodel/security/SecurityConfiguration.java @@ -3,6 +3,7 @@ package cn.teammodel.security; import cn.teammodel.security.filter.AuthInnerTokenFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; @@ -20,6 +21,7 @@ import javax.annotation.Resource; @Configuration @EnableWebSecurity +@EnableMethodSecurity public class SecurityConfiguration { @Resource private AuthInnerTokenFilter authInnerTokenFilter; diff --git a/src/main/java/cn/teammodel/security/filter/AuthInnerTokenFilter.java b/src/main/java/cn/teammodel/security/filter/AuthInnerTokenFilter.java index b1f6eb4..6a61b1d 100644 --- a/src/main/java/cn/teammodel/security/filter/AuthInnerTokenFilter.java +++ b/src/main/java/cn/teammodel/security/filter/AuthInnerTokenFilter.java @@ -1,5 +1,17 @@ package cn.teammodel.security.filter; +import cn.teammodel.config.exception.ServiceException; +import cn.teammodel.model.entity.TmdUserDetail; +import cn.teammodel.security.utils.JwtTokenUtil; +import cn.teammodel.security.utils.SecurityUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -8,6 +20,7 @@ import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.Collection; /** * x-auth-authToken filter @@ -15,9 +28,28 @@ import java.io.IOException; * @create 2023-11-09 10:43 */ @Component +@Slf4j public class AuthInnerTokenFilter extends OncePerRequestFilter { + + @Autowired + JwtTokenUtil jwtTokenUtil; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + SecurityContext context = SecurityContextHolder.getContext(); + Authentication authentication = context.getAuthentication(); + // 进入此过滤器说明 OIDC 认证成功,则验证 authToken + // 验证 authToken 合法 + TmdUserDetail tmdUserDetail = jwtTokenUtil.getValidUserDetail(request); + if (tmdUserDetail == null) { + log.error("authToken authentication failed"); + throw new ServiceException("无权限"); + } + System.out.println(tmdUserDetail.getUser()); + // 组装 authToken 的 jwt 进 authentication + Collection authorities = authentication.getAuthorities(); + UsernamePasswordAuthenticationToken finalAuthentication = new UsernamePasswordAuthenticationToken(tmdUserDetail, null, authorities); + context.setAuthentication(finalAuthentication); filterChain.doFilter(request, response); } } diff --git a/src/main/java/cn/teammodel/security/service/PermissionService.java b/src/main/java/cn/teammodel/security/service/PermissionService.java new file mode 100644 index 0000000..ddaad19 --- /dev/null +++ b/src/main/java/cn/teammodel/security/service/PermissionService.java @@ -0,0 +1,174 @@ +package cn.teammodel.security.service; + +import java.util.Set; + +import cn.teammodel.model.entity.User; +import cn.teammodel.security.utils.SecurityUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +/** + * 自定义权限实现,ss取自SpringSecurity首字母
+ *

+ * 1. IES 类似角色判断: hasAuth
+ * 2. role 身份判断(authToken 解析出来的用户身份): hasRole
+ * 3. permission 权限判断(authToken 解析出来的用户权限): hasPermi
+ *

+ * @author ruoyi + */ +@Service("ss") +public class PermissionService +{ + /** 所有权限标识 */ + private static final String ALL_PERMISSION = "*:*:*"; + + /** 管理员角色权限标识 */ + private static final String SUPER_ADMIN = "admin"; + + private static final String ROLE_DELIMETER = ","; + + private static final String PERMISSION_DELIMETER = ","; + + + + + /** + * access_token 是否拥有 auth 角色 + * @param auth: + * @return: boolean + * @author: winter + * @date: 2023/11/10 10:05 + * @description: + */ + public boolean hasAuth(String auth) + { + if (StringUtils.isEmpty(auth)) + { + return false; + } + Authentication authentication = SecurityUtils.getAuthentication(); + if (authentication == null || CollectionUtils.isEmpty(authentication.getAuthorities())) + { + return false; + } + return authentication.getAuthorities().contains(auth); + } + + /** + * 验证用户是否具备某权限 + * + * @param permission 权限字符串 + * @return 用户是否具备某权限 + */ + public boolean hasPermi(String permission) + { + if (StringUtils.isEmpty(permission)) + { + return false; + } + User loginUser = SecurityUtils.getLoginUser(); + if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions())) + { + return false; + } + return loginUser.getPermissions().contains(permission); + } + + /** + * 验证用户是否不具备某权限,与 hasPermi逻辑相反 + * + * @param permission 权限字符串 + * @return 用户是否不具备某权限 + */ + public boolean lacksPermi(String permission) + { + return !hasPermi(permission); + } + + /** + * 验证用户是否具有以下任意一个权限 + * + * @param permissions 以 PERMISSION_DELIMETER 为分隔符的权限列表 + * @return 用户是否具有以下任意一个权限 + */ + public boolean hasAnyPermi(String permissions) + { + if (StringUtils.isEmpty(permissions)) + { + return false; + } + User loginUser = SecurityUtils.getLoginUser(); + if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions())) + { + return false; + } + Set authorities = loginUser.getPermissions(); + for (String permission : permissions.split(PERMISSION_DELIMETER)) + { + if (permission != null && authorities.contains(permission)) + { + return true; + } + } + return false; + } + + /** + * 判断用户是否拥有某个角色 + * + * @param role 角色字符串 + * @return 用户是否具备某角色 + */ + public boolean hasRole(String role) + { + if (StringUtils.isEmpty(role)) { + return false; + } + User loginUser = SecurityUtils.getLoginUser(); + if (loginUser == null || CollectionUtils.isEmpty(loginUser.getRoles())) + { + return false; + } + return loginUser.getRoles().contains(role); + } + + /** + * 验证用户是否不具备某角色,与 isRole逻辑相反。 + * + * @param role 角色名称 + * @return 用户是否不具备某角色 + */ + public boolean lacksRole(String role) + { + return !hasRole(role); + } + + /** + * 验证用户是否具有以下任意一个角色 + * + * @param roles 以 ROLE_NAMES_DELIMETER 为分隔符的角色列表 + * @return 用户是否具有以下任意一个角色 + */ + public boolean hasAnyRoles(String roles) + { + if (StringUtils.isEmpty(roles)) + { + return false; + } + User loginUser = SecurityUtils.getLoginUser(); + if (loginUser == null || CollectionUtils.isEmpty(loginUser.getRoles())) + { + return false; + } + for (String role : roles.split(ROLE_DELIMETER)) + { + if (hasRole(role)) + { + return true; + } + } + return false; + } +} diff --git a/src/main/java/cn/teammodel/security/utils/JwtTokenUtil.java b/src/main/java/cn/teammodel/security/utils/JwtTokenUtil.java index 16bb8c1..32c0e95 100644 --- a/src/main/java/cn/teammodel/security/utils/JwtTokenUtil.java +++ b/src/main/java/cn/teammodel/security/utils/JwtTokenUtil.java @@ -1,16 +1,21 @@ package cn.teammodel.security.utils; +import cn.teammodel.model.entity.User; +import cn.teammodel.model.entity.TmdUserDetail; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; +import javax.crypto.spec.SecretKeySpec; +import javax.servlet.http.HttpServletRequest; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; /** * @author winter @@ -25,23 +30,21 @@ public class JwtTokenUtil { @Value("${jwt.secret}") private String secret; - @Value("${jwt.expiration}") - private Long expiration; - @Value("${jwt.tokenHead}") - private String tokenHead; + private Integer expiration = 30; /** * 生成token * @param userDetails 传入userDetails * @return token */ + @Deprecated public String generateToken(UserDetails userDetails){ Map claims = new HashMap<>(); claims.put(CLAIM_KEY_USERNAME,userDetails.getUsername()); claims.put(CLAIM_KEY_CREATED,new Date()); return generateToken(claims); } - + @Deprecated private String generateToken(Map claims){ return Jwts.builder() .setClaims(claims) @@ -63,6 +66,7 @@ public class JwtTokenUtil { * @param token 前端传入的token * @return 负载中的用户名,获取失败返回null */ + @Deprecated public String getUsernameFromToken(String token) { String username = null; try { @@ -79,15 +83,14 @@ public class JwtTokenUtil { * @param token token * @return claims或者null */ - private Claims getClaimsFromToken(String token){ + private Claims getClaimsFromToken(String token) { Claims claims = null; try { claims = Jwts.parser() - .setSigningKey(secret) + .setSigningKey(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")) .parseClaimsJws(token) .getBody(); - } catch (Exception e){ - e.printStackTrace(); + } catch (Exception e) { log.info("jwt解析出错:{}",token); } return claims; @@ -96,13 +99,21 @@ public class JwtTokenUtil { /** * 检验token是否还有效 * @param token 前端传入的token - * @param userDetails 登录用户细节 * @return true表示有效,false表示无效 */ public boolean validateToken(String token) { return !isTokenExpired(token); } + public boolean validateToken(HttpServletRequest request) { + String token = request.getHeader("x-auth-AuthToken"); + if (StringUtils.isBlank(token)) { + return false; + } + + return validateToken(token); + } + private boolean isTokenExpired(String token) { Date expiredDate = getExpiredDateFromToken(token); return expiredDate.before(new Date()); @@ -112,4 +123,60 @@ public class JwtTokenUtil { Claims claims = getClaimsFromToken(token); return claims.getExpiration(); } + + /** + * token 验证成功返回实体,验证失败返回 null + * @param request: + * @return: cn.teammodel.model.entity.TmdUserDetail + * @author: winter + * @date: 2023/11/10 10:24 + * @description: + */ + public TmdUserDetail getValidUserDetail(HttpServletRequest request) { + String token = request.getHeader("x-auth-AuthToken"); + if (StringUtils.isBlank(token)) { + return null; + } + Claims claims = getClaimsFromToken(token); + if (claims == null) { + return null; + } + + // 组装 TmdUserDetail + TmdUserDetail tmdUserDetail = new TmdUserDetail(); + User user = new User(); + String id = claims.getSubject(); + user.setId(id); + user.setName(claims.get("name") == null ? null : claims.get("name", String.class)); + user.setPicture(claims.get("picture") == null ? null : claims.get("picture", String.class)); + user.setStandard(claims.get("standard") == null ? null : claims.get("standard", String.class)); + user.setScope(claims.get("scope") == null ? null : claims.get("scope", String.class)); + user.setWebsite(claims.get("website") == null ? null : claims.get("website", String.class)); + user.setArea(claims.get("area") == null ? null : claims.get("area", String.class)); + + // 取出 roles 和 permissions + Set roleSet = convertToArray(claims.get("roles")); + Set permissionSet = convertToArray(claims.get("permissions")); + user.setRoles(roleSet); + user.setPermissions(permissionSet); + tmdUserDetail.setUser(user); + return tmdUserDetail; + } + + @SuppressWarnings("unchecked") + private Set convertToArray(Object o) { + if (o == null) { + return Collections.emptySet(); + } + if (o instanceof String) { + if (StringUtils.isNotBlank((String)o)) { + return Arrays.stream(((String) o).split(" ")).collect(Collectors.toSet()); + } + return Collections.emptySet(); + } + if (o instanceof Collection) { + return new HashSet<>(((Collection) o)); + } + return Collections.emptySet(); + } } \ No newline at end of file diff --git a/src/main/java/cn/teammodel/security/utils/SecurityUtils.java b/src/main/java/cn/teammodel/security/utils/SecurityUtils.java new file mode 100644 index 0000000..a667415 --- /dev/null +++ b/src/main/java/cn/teammodel/security/utils/SecurityUtils.java @@ -0,0 +1,107 @@ +package cn.teammodel.security.utils; + +import cn.teammodel.config.exception.ServiceException; +import cn.teammodel.model.entity.User; +import cn.teammodel.model.entity.TmdUserDetail; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +/** + * 安全服务工具类 + * + * @author winter + */ +public class SecurityUtils +{ + /** + * 用户ID + **/ + public static String getUserId() + { + try + { + return getLoginUser().getId(); + } + catch (Exception e) + { + throw new ServiceException("获取用户ID异常", HttpStatus.UNAUTHORIZED.value()); + } + } + + + /** + * 获取用户账户 + **/ + public static String getUsername() + { + try + { + return getLoginUser().getName(); + } + catch (Exception e) + { + throw new ServiceException("获取用户账户异常", HttpStatus.UNAUTHORIZED.value()); + } + } + + /** + * 获取用户 + **/ + public static User getLoginUser() + { + try + { + return ((TmdUserDetail) getAuthentication().getPrincipal()).getUser(); + } + catch (Exception e) + { + throw new ServiceException("获取用户信息异常", HttpStatus.UNAUTHORIZED.value()); + } + } + + /** + * 获取Authentication + */ + public static Authentication getAuthentication() + { + return SecurityContextHolder.getContext().getAuthentication(); + } + + /** + * 生成BCryptPasswordEncoder密码 + * + * @param password 密码 + * @return 加密字符串 + */ + public static String encryptPassword(String password) + { + BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + return passwordEncoder.encode(password); + } + + /** + * 判断密码是否相同 + * + * @param rawPassword 真实密码 + * @param encodedPassword 加密后字符 + * @return 结果 + */ + public static boolean matchesPassword(String rawPassword, String encodedPassword) + { + BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + return passwordEncoder.matches(rawPassword, encodedPassword); + } + + /** + * 是否为管理员 + * + * @param userId 用户ID + * @return 结果 + */ + public static boolean isAdmin(Long userId) + { + return userId != null && 1L == userId; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 93c87b7..74b1bdd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,4 +11,7 @@ spring: resourceserver: jwt: issuer-uri: https://login.partner.microsoftonline.cn/4807e9cf-87b8-4174-aa5b-e76497d7392b/v2.0 - audiences: 72643704-b2e7-4b26-b881-bd5865e7a7a5 \ No newline at end of file + audiences: 72643704-b2e7-4b26-b881-bd5865e7a7a5 + +jwt: + secret: fXO6ko/qyXeYrkecPeKdgXnuLXf9vMEtnBC9OB3s+aA= \ No newline at end of file diff --git a/src/test/java/cn/teammodel/DeploymentTestApplicationTests.java b/src/test/java/cn/teammodel/TeamModelExtensionApplicationTests.java similarity index 80% rename from src/test/java/cn/teammodel/DeploymentTestApplicationTests.java rename to src/test/java/cn/teammodel/TeamModelExtensionApplicationTests.java index 2600be7..a178f89 100644 --- a/src/test/java/cn/teammodel/DeploymentTestApplicationTests.java +++ b/src/test/java/cn/teammodel/TeamModelExtensionApplicationTests.java @@ -4,7 +4,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -class DeploymentTestApplicationTests { +class TeamModelExtensionApplicationTests { @Test void contextLoads() { diff --git a/src/test/java/cn/teammodel/TestWithoutSpring.java b/src/test/java/cn/teammodel/TestWithoutSpring.java new file mode 100644 index 0000000..484bc84 --- /dev/null +++ b/src/test/java/cn/teammodel/TestWithoutSpring.java @@ -0,0 +1,25 @@ +package cn.teammodel; + +import org.junit.jupiter.api.Test; + +/** + * @author winter + * @create 2023-11-10 10:42 + */ +public class TestWithoutSpring { + @Test + public void testJwtDecode() { +// Claims claims = null; +// try { +// Jwts.parser() +// +// +//// claims = Jwts.parser() +//// .setSigningKey("fXO6ko/qyXeYrkecPeKdgXnuLXf9vMEtnBC9OB3s+aA=") +//// .parseClaimsJws("eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNobWFjLXNoYTI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0ZXN0LnRlYW1tb2RlbC5jbiIsInN1YiI6IjE1OTUzMjEzNTQiLCJhenAiOiJoYmNuIiwiZXhwIjoxNjk5NTg2MzM1LCJuYW1lIjoi572X6ICB5biIIiwicGljdHVyZSI6Imh0dHBzOi8vY29yZXN0b3JhZ2VzZXJ2aWNlLmJsb2IuY29yZS5jaGluYWNsb3VkYXBpLmNuL2FjY291bnQvYXZhdGFyLzE1OTUzMjEzNTQiLCJyb2xlcyI6WyJ0ZWFjaGVyIiwiYWRtaW4iLCJhcmVhIl0sInBlcm1pc3Npb25zIjpbXSwic3RhbmRhcmQiOiJzdGFuZGFyZDEiLCJzY29wZSI6InRlYWNoZXIiLCJhcmVhIjoiNjllM2Q0MTMtNTBhMS00ZjVlLTg0NGEtZTBmN2M5NjIyZWEzIiwid2Vic2l0ZSI6IklFUyJ9.4NdqwDdDQcyberEAirX0srOIb1ADXLJfP-a9qNXb0yw") +//// .getBody(); +// } catch (Exception e) { +// e.printStackTrace(); +// } + } +}