添加依赖 <!-- spring security --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- jwt --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
添加配置
package com.louis.mango.admin.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; 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.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; import com.louis.mango.admin.security.JwtAuthenticationFilter; import com.louis.mango.admin.security.JwtAuthenticationProvider; /** * Spring Security配置 * @author Louis * @date Jan 14, 2019 */ @Configuration @EnableWebSecurity // 开启Spring Security @EnableGlobalMethodSecurity(prePostEnabled = true) // 开启权限注解,如:@PreAuthorize注解 public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserDetailsService userDetailsService; @Override public void configure(AuthenticationManagerBuilder auth) throws Exception { // 使用自定义身份验证组件 auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService)); } @Override protected void configure(HttpSecurity http) throws Exception { // 禁用 csrf, 由于使用的是JWT,我们这里不需要csrf http.cors().and().csrf().disable() .authorizeRequests() // 跨域预检请求 .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() // web jars .antMatchers("/webjars/**").permitAll() // 查看SQL监控(druid) .antMatchers("/druid/**").permitAll() // 首页和登录页面 .antMatchers("/").permitAll() .antMatchers("/login").permitAll() // swagger .antMatchers("/swagger-ui.html").permitAll() .antMatchers("/swagger-resources/**").permitAll() .antMatchers("/v2/api-docs").permitAll() .antMatchers("/webjars/springfox-swagger-ui/**").permitAll() // 验证码 .antMatchers("/captcha.jpg**").permitAll() // 服务监控 .antMatchers("/actuator/**").permitAll() // 其他所有请求需要身份认证 .anyRequest().authenticated(); // 退出登录处理器 http.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()); // token验证过滤器 http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class); } @Bean @Override public AuthenticationManager authenticationManager() throws Exception { return super.authenticationManager(); } }
package com.louis.mango.admin.security; import java.io.IOException; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import com.louis.mango.admin.util.SecurityUtils; /** * 登录认证过滤器 * @author Louis * @date Jan 14, 2019 */ public class JwtAuthenticationFilter extends BasicAuthenticationFilter { @Autowired public JwtAuthenticationFilter(AuthenticationManager authenticationManager) { super(authenticationManager); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { // 获取token, 并检查登录状态 SecurityUtils.checkAuthentication(request); chain.doFilter(request, response); } }
package com.louis.mango.admin.util; import javax.servlet.http.HttpServletRequest; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import com.louis.mango.admin.security.JwtAuthenticatioToken; /** * Security相关操作 * @author Louis * @date Jan 14, 2019 */ public class SecurityUtils { /** * 系统登录认证 * @param request * @param username * @param password * @param authenticationManager * @return */ public static JwtAuthenticatioToken login(HttpServletRequest request, String username, String password, AuthenticationManager authenticationManager) { JwtAuthenticatioToken token = new JwtAuthenticatioToken(username, password); token.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 执行登录认证过程 Authentication authentication = authenticationManager.authenticate(token); // 认证成功存储认证信息到上下文 SecurityContextHolder.getContext().setAuthentication(authentication); // 生成令牌并返回给客户端 token.setToken(JwtTokenUtils.generateToken(authentication)); return token; } /** * 获取令牌进行认证 * @param request */ public static void checkAuthentication(HttpServletRequest request) { // 获取令牌并根据令牌获取登录认证信息 Authentication authentication = JwtTokenUtils.getAuthenticationeFromToken(request); // 设置登录认证信息到上下文 SecurityContextHolder.getContext().setAuthentication(authentication); } /** * 获取当前用户名 * @return */ public static String getUsername() { String username = null; Authentication authentication = getAuthentication(); if(authentication != null) { Object principal = authentication.getPrincipal(); if(principal != null && principal instanceof UserDetails) { username = ((UserDetails) principal).getUsername(); } } return username; } /** * 获取用户名 * @return */ public static String getUsername(Authentication authentication) { String username = null; if(authentication != null) { Object principal = authentication.getPrincipal(); if(principal != null && principal instanceof UserDetails) { username = ((UserDetails) principal).getUsername(); } } return username; } /** * 获取当前登录信息 * @return */ public static Authentication getAuthentication() { if(SecurityContextHolder.getContext() == null) { return null; } Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return authentication; } }
package com.louis.mango.admin.security; import java.util.Collection; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; /** * 自定义令牌对象 * @author Louis * @date Jan 14, 2019 */ public class JwtAuthenticatioToken extends UsernamePasswordAuthenticationToken { private static final long serialVersionUID = 1L; private String token; public JwtAuthenticatioToken(Object principal, Object credentials){ super(principal, credentials); } public JwtAuthenticatioToken(Object principal, Object credentials, String token){ super(principal, credentials); this.token = token; } public JwtAuthenticatioToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities, String token) { super(principal, credentials, authorities); this.token = token; } public String getToken() { return token; } public void setToken(String token) { this.token = token; } public static long getSerialversionuid() { return serialVersionUID; } }
package com.louis.mango.admin.util; import java.io.Serializable; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.servlet.http.HttpServletRequest; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import com.louis.mango.admin.security.GrantedAuthorityImpl; import com.louis.mango.admin.security.JwtAuthenticatioToken; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; /** * JWT工具类 * @author Louis * @date Jan 14, 2019 */ public class JwtTokenUtils implements Serializable { private static final long serialVersionUID = 1L; /** * 用户名称 */ private static final String USERNAME = Claims.SUBJECT; /** * 创建时间 */ private static final String CREATED = "created"; /** * 权限列表 */ private static final String AUTHORITIES = "authorities"; /** * 密钥 */ private static final String SECRET = "abcdefgh"; /** * 有效期12小时 */ private static final long EXPIRE_TIME = 12 * 60 * 60 * 1000; /** * 生成令牌 * * @param userDetails 用户 * @return 令牌 */ public static String generateToken(Authentication authentication) { Map<String, Object> claims = new HashMap<>(3); claims.put(USERNAME, SecurityUtils.getUsername(authentication)); claims.put(CREATED, new Date()); claims.put(AUTHORITIES, authentication.getAuthorities()); return generateToken(claims); } /** * 从数据声明生成令牌 * * @param claims 数据声明 * @return 令牌 */ private static String generateToken(Map<String, Object> claims) { Date expirationDate = new Date(System.currentTimeMillis() + EXPIRE_TIME); return Jwts.builder().setClaims(claims).setExpiration(expirationDate).signWith(SignatureAlgorithm.HS512, SECRET).compact(); } /** * 从令牌中获取用户名 * * @param token 令牌 * @return 用户名 */ public static 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 static Authentication getAuthenticationeFromToken(HttpServletRequest request) { Authentication authentication = null; // 获取请求携带的令牌 String token = JwtTokenUtils.getToken(request); if(token != null) { // 请求令牌不能为空 if(SecurityUtils.getAuthentication() == null) { // 上下文中Authentication为空 Claims claims = getClaimsFromToken(token); if(claims == null) { return null; } String username = claims.getSubject(); if(username == null) { return null; } if(isTokenExpired(token)) { return null; } Object authors = claims.get(AUTHORITIES); List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>(); if (authors != null && authors instanceof List) { for (Object object : (List) authors) { authorities.add(new GrantedAuthorityImpl((String) ((Map) object).get("authority"))); } } authentication = new JwtAuthenticatioToken(username, null, authorities, token); } else { if(validateToken(token, SecurityUtils.getUsername())) { // 如果上下文中Authentication非空,且请求令牌合法,直接返回当前登录认证信息 authentication = SecurityUtils.getAuthentication(); } } } return authentication; } /** * 从令牌中获取数据声明 * * @param token 令牌 * @return 数据声明 */ private static Claims getClaimsFromToken(String token) { Claims claims; try { claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } catch (Exception e) { claims = null; } return claims; } /** * 验证令牌 * @param token * @param username * @return */ public static Boolean validateToken(String token, String username) { String userName = getUsernameFromToken(token); return (userName.equals(username) && !isTokenExpired(token)); } /** * 刷新令牌 * @param token * @return */ public static 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 令牌 * @return 是否过期 */ public static Boolean isTokenExpired(String token) { try { Claims claims = getClaimsFromToken(token); Date expiration = claims.getExpiration(); return expiration.before(new Date()); } catch (Exception e) { return false; } } /** * 获取请求token * @param request * @return */ public static String getToken(HttpServletRequest request) { String token = request.getHeader("Authorization"); String tokenHead = "Bearer "; if(token == null) { token = request.getHeader("token"); } else if(token.contains(tokenHead)){ token = token.substring(tokenHead.length()); } if("".equals(token)) { token = null; } return token; } }
package com.louis.mango.admin.security; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import com.louis.mango.admin.util.PasswordEncoder; /** * 身份验证提供者 * @author Louis * @date Jan 14, 2019 */ public class JwtAuthenticationProvider extends DaoAuthenticationProvider { public JwtAuthenticationProvider(UserDetailsService userDetailsService) { setUserDetailsService(userDetailsService); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { if (authentication.getCredentials() == null) { logger.debug("Authentication failed: no credentials provided"); throw new BadCredentialsException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } String presentedPassword = authentication.getCredentials().toString(); String salt = ((JwtUserDetails) userDetails).getSalt(); // 覆写密码验证逻辑 if (!new PasswordEncoder(salt).matches(userDetails.getPassword(), presentedPassword)) { logger.debug("Authentication failed: password does not match stored value"); throw new BadCredentialsException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } } }
package com.louis.mango.admin.util; import java.security.MessageDigest; /** * 密码加密 * @author Louis * @date Jan 13, 2019 */ public class PasswordEncoder { private final static String[] hexDigits = { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f" }; private final static String MD5 = "MD5"; //private final static String SHA = "SHA"; private Object salt; private String algorithm; public PasswordEncoder(Object salt) { this(salt, MD5); } public PasswordEncoder(Object salt, String algorithm) { this.salt = salt; this.algorithm = algorithm; } /** * 密码加密 * @param rawPass * @return */ public String encode(String rawPass) { String result = null; try { MessageDigest md = MessageDigest.getInstance(algorithm); // 加密后的字符串 result = byteArrayToHexString(md.digest(mergePasswordAndSalt(rawPass).getBytes("utf-8"))); } catch (Exception ex) { } return result; } /** * 密码匹配验证 * @param encPass 密文 * @param rawPass 明文 * @return */ public boolean matches(String encPass, String rawPass) { String pass1 = "" + encPass; String pass2 = encode(rawPass); return pass1.equals(pass2); } private String mergePasswordAndSalt(String password) { if (password == null) { password = ""; } if ((salt == null) || "".equals(salt)) { return password; } else { return password + "{" + salt.toString() + "}"; } } /** * 转换字节数组为16进制字串 * * @param b * 字节数组 * @return 16进制字串 */ private String byteArrayToHexString(byte[] b) { StringBuffer resultSb = new StringBuffer(); for (int i = 0; i < b.length; i++) { resultSb.append(byteToHexString(b[i])); } return resultSb.toString(); } /** * 将字节转换为16进制 * @param b * @return */ private static String byteToHexString(byte b) { int n = b; if (n < 0) n = 256 + n; int d1 = n / 16; int d2 = n % 16; return hexDigits[d1] + hexDigits[d2]; } // public static void main(String[] args) { // String salt = "helloworld"; // PasswordEncoder encoderMd5 = new PasswordEncoder(salt, "MD5"); // String encode = encoderMd5.encode("test"); // System.out.println(encode); // boolean passwordValid = encoderMd5.validPassword("1bd98ed329aebc7b2f89424b5a38926e", "test"); // System.out.println(passwordValid); // // PasswordEncoder encoderSha = new PasswordEncoder(salt, "SHA"); // String pass2 = encoderSha.encode("test"); // System.out.println(pass2); // boolean passwordValid2 = encoderSha.validPassword("1bd98ed329aebc7b2f89424b5a38926e", "test"); // System.out.println(passwordValid2); // } }
package com.louis.mango.admin.security; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import com.louis.mango.admin.model.SysUser; import com.louis.mango.admin.service.SysUserService; /** * 用户登录认证信息查询 * @author Louis * @date Jan 14, 2019 */ @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private SysUserService sysUserService; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user = sysUserService.findByName(username); if (user == null) { throw new UsernameNotFoundException("该用户不存在"); } // 用户权限列表,根据用户拥有的权限标识与如 @PreAuthorize("hasAuthority('sys:menu:view')") 标注的接口对比,决定是否可以调用接口 Set<String> permissions = sysUserService.findPermissions(user.getName()); List<GrantedAuthority> grantedAuthorities = permissions.stream().map(GrantedAuthorityImpl::new).collect(Collectors.toList()); return new JwtUserDetails(user.getName(), user.getPassword(), user.getSalt(), grantedAuthorities); } }
package com.louis.mango.admin.model; import java.util.ArrayList; import java.util.List; public class SysUser extends BaseModel { private String name; private String nickName; private String avatar; private String password; private String salt; private String email; private String mobile; private Byte status; private Long deptId; private Byte delFlag; // 非数据库字段 private String deptName; // 非数据库字段 private String roleNames; // 非数据库字段 private List<SysUserRole> userRoles = new ArrayList<>(); public String getName() { return name; } public void setName(String name) { this.name = name; } public String getNickName() { return nickName; } public void setNickName(String nickName) { this.nickName = nickName; } public String getAvatar() { return avatar; } public void setAvatar(String avatar) { this.avatar = avatar; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getSalt() { return salt; } public void setSalt(String salt) { this.salt = salt; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getMobile() { return mobile; } public void setMobile(String mobile) { this.mobile = mobile; } public Byte getStatus() { return status; } public void setStatus(Byte status) { this.status = status; } public Long getDeptId() { return deptId; } public void setDeptId(Long deptId) { this.deptId = deptId; } public Byte getDelFlag() { return delFlag; } public void setDelFlag(Byte delFlag) { this.delFlag = delFlag; } public String getDeptName() { return deptName; } public void setDeptName(String deptName) { this.deptName = deptName; } public String getRoleNames() { return roleNames; } public void setRoleNames(String roleNames) { this.roleNames = roleNames; } public List<SysUserRole> getUserRoles() { return userRoles; } public void setUserRoles(List<SysUserRole> userRoles) { this.userRoles = userRoles; } }
package com.louis.mango.admin.service; import java.io.File; import java.util.List; import java.util.Set; import com.louis.mango.admin.model.SysUser; import com.louis.mango.admin.model.SysUserRole; import com.louis.mango.core.page.PageRequest; import com.louis.mango.core.service.CurdService; /** * 用户管理 * @author Louis * @date Jan 13, 2019 */ public interface SysUserService extends CurdService<SysUser> { SysUser findByName(String username); /** * 查找用户的菜单权限标识集合 * @param userName * @return */ Set<String> findPermissions(String userName); /** * 查找用户的角色集合 * @param userName * @return */ List<SysUserRole> findUserRoles(Long userId); /** * 生成用户信息Excel文件 * @param pageRequest 要导出的分页查询参数 * @return */ File createUserExcelFile(PageRequest pageRequest); }
package com.louis.mango.admin.security; import java.util.Collection; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import com.fasterxml.jackson.annotation.JsonIgnore; /** * 安全用户模型 * @author Louis * @date Jan 14, 2019 */ public class JwtUserDetails implements UserDetails { private static final long serialVersionUID = 1L; private String username; private String password; private String salt; private Collection<? extends GrantedAuthority> authorities; JwtUserDetails(String username, String password, String salt, Collection<? extends GrantedAuthority> authorities) { this.username = username; this.password = password; this.salt = salt; this.authorities = authorities; } @Override public String getUsername() { return username; } @JsonIgnore @Override public String getPassword() { return password; } public String getSalt() { return salt; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } @JsonIgnore @Override public boolean isAccountNonExpired() { return true; } @JsonIgnore @Override public boolean isAccountNonLocked() { return true; } @JsonIgnore @Override public boolean isCredentialsNonExpired() { return true; } @JsonIgnore @Override public boolean isEnabled() { return true; } }
package com.louis.mango.admin.security; import org.springframework.security.core.GrantedAuthority; /** * 权限封装 * @author Louis * @date Jan 14, 2019 */ public class GrantedAuthorityImpl implements GrantedAuthority { private static final long serialVersionUID = 1L; private String authority; public GrantedAuthorityImpl(String authority) { this.authority = authority; } public void setAuthority(String authority) { this.authority = authority; } @Override public String getAuthority() { return this.authority; } }
package com.louis.mango.admin.controller; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.louis.mango.admin.model.SysDict; import com.louis.mango.admin.service.SysDictService; import com.louis.mango.core.http.HttpResult; import com.louis.mango.core.page.PageRequest; /** * 字典控制器 * @author Louis * @date Jan 13, 2019 */ @RestController @RequestMapping("dict") public class SysDictController { @Autowired private SysDictService sysDictService; @PreAuthorize("hasAuthority('sys:dict:add') AND hasAuthority('sys:dict:edit')") @PostMapping(value="/save") public HttpResult save(@RequestBody SysDict record) { return HttpResult.ok(sysDictService.save(record)); } @PreAuthorize("hasAuthority('sys:dict:delete')") @PostMapping(value="/delete") public HttpResult delete(@RequestBody List<SysDict> records) { return HttpResult.ok(sysDictService.delete(records)); } @PreAuthorize("hasAuthority('sys:dict:view')") @PostMapping(value="/findPage") public HttpResult findPage(@RequestBody PageRequest pageRequest) { return HttpResult.ok(sysDictService.findPage(pageRequest)); } @PreAuthorize("hasAuthority('sys:dict:view')") @GetMapping(value="/findByLable") public HttpResult findByLable(@RequestParam String lable) { return HttpResult.ok(sysDictService.findByLable(lable)); } }
package com.louis.mango.admin.config; import java.util.ArrayList; import java.util.List; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import springfox.documentation.builders.ApiInfoBuilder; import springfox.documentation.builders.ParameterBuilder; import springfox.documentation.builders.PathSelectors; import springfox.documentation.builders.RequestHandlerSelectors; import springfox.documentation.schema.ModelRef; import springfox.documentation.service.ApiInfo; import springfox.documentation.service.Parameter; import springfox.documentation.spi.DocumentationType; import springfox.documentation.spring.web.plugins.Docket; import springfox.documentation.swagger2.annotations.EnableSwagger2; /** * Swagger配置 * * @author Louis * @date Jan 11, 2019 */ @Configuration @EnableSwagger2 public class SwaggerConfig { @Bean public Docket createRestApi() { // 添加请求参数,我们这里把token作为请求头部参数传入后端 ParameterBuilder parameterBuilder = new ParameterBuilder(); List<Parameter> parameters = new ArrayList<Parameter>(); parameterBuilder.name("token").description("令牌").modelRef(new ModelRef("string")).parameterType("header") .required(false).build(); parameters.add(parameterBuilder.build()); return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select().apis(RequestHandlerSelectors.any()) .paths(PathSelectors.any()).build().globalOperationParameters(parameters); // return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select().apis(RequestHandlerSelectors.any()) // .paths(PathSelectors.any()).build(); } private ApiInfo apiInfo() { return new ApiInfoBuilder().build(); } }