KEYCLOAK-44
Add configuration of TOTP to registration
|
@ -13,6 +13,11 @@
|
|||
<description />
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-services</artifactId>
|
||||
|
@ -23,11 +28,16 @@
|
|||
<artifactId>keycloak-social-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jboss.resteasy</groupId>
|
||||
<artifactId>jaxrs-api</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.picketlink</groupId>
|
||||
<artifactId>picketlink-common</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jboss.resteasy</groupId>
|
||||
<artifactId>resteasy-jaxrs</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jboss.spec.javax.servlet</groupId>
|
||||
<artifactId>jboss-servlet-api_3.0_spec</artifactId>
|
||||
|
@ -38,6 +48,14 @@
|
|||
<artifactId>jsf-api</artifactId>
|
||||
<version>2.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>javase</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
@ -21,12 +21,15 @@
|
|||
*/
|
||||
package org.keycloak.forms;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import javax.faces.bean.ManagedBean;
|
||||
|
@ -37,10 +40,12 @@ import javax.servlet.http.HttpServletRequest;
|
|||
import javax.ws.rs.core.MultivaluedMap;
|
||||
import javax.ws.rs.core.UriBuilder;
|
||||
|
||||
import org.keycloak.representations.idm.CredentialRepresentation;
|
||||
import org.keycloak.services.models.RealmModel;
|
||||
import org.keycloak.services.models.RequiredCredentialModel;
|
||||
import org.keycloak.services.resources.flows.FormFlows;
|
||||
import org.keycloak.services.resources.flows.Urls;
|
||||
import org.picketlink.common.util.Base32;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
|
@ -81,6 +86,10 @@ public class FormsBean {
|
|||
|
||||
private Map<String, String> formData;
|
||||
|
||||
private String totpSecret;
|
||||
|
||||
private String formsUrl;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
FacesContext ctx = FacesContext.getCurrentInstance();
|
||||
|
@ -99,7 +108,7 @@ public class FormsBean {
|
|||
|
||||
view = ctx.getViewRoot().getViewId();
|
||||
view = view.substring(view.lastIndexOf('/') + 1, view.lastIndexOf('.'));
|
||||
|
||||
|
||||
UriBuilder b = UriBuilder.fromUri(request.getRequestURI()).replaceQuery(request.getQueryString())
|
||||
.replacePath(request.getContextPath()).path("rest");
|
||||
URI baseURI = b.build();
|
||||
|
@ -125,9 +134,11 @@ public class FormsBean {
|
|||
addSocialProviders();
|
||||
addErrors(request);
|
||||
|
||||
formsUrl = FacesContext.getCurrentInstance().getExternalContext().getRequestContextPath() + "/forms";
|
||||
|
||||
// TODO Get theme name from realm
|
||||
theme = "default";
|
||||
themeUrl = FacesContext.getCurrentInstance().getExternalContext().getRequestContextPath() + "/sdk/theme/" + theme;
|
||||
themeUrl = formsUrl + "/theme/" + theme;
|
||||
|
||||
themeConfig = new HashMap<String, Object>();
|
||||
|
||||
|
@ -220,10 +231,34 @@ public class FormsBean {
|
|||
for (RequiredCredentialModel m : realm.getRequiredCredentials()) {
|
||||
if (m.isInput()) {
|
||||
requiredCredentials.add(new RequiredCredential(m.getType(), m.isSecret(), m.getFormLabel()));
|
||||
|
||||
if (m.getType().equals(CredentialRepresentation.TOTP)) {
|
||||
if (formData != null) {
|
||||
totpSecret = formData.get("totpSecret");
|
||||
}
|
||||
|
||||
if (totpSecret == null) {
|
||||
totpSecret = UUID.randomUUID().toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isTotp() {
|
||||
return totpSecret != null;
|
||||
}
|
||||
|
||||
public String getTotpSecret() {
|
||||
return totpSecret;
|
||||
}
|
||||
|
||||
public String getTotpSecretQrCodeUrl() throws UnsupportedEncodingException {
|
||||
String totpSecretEncoded = Base32.encode(getTotpSecret().getBytes());
|
||||
String contents = URLEncoder.encode("otpauth://totp/keycloak?secret=" + totpSecretEncoded, "utf-8");
|
||||
return formsUrl + "/qrcode" + "?size=200x200&contents=" + contents;
|
||||
}
|
||||
|
||||
private void addSocialProviders() {
|
||||
// TODO Add providers configured for realm instead of all providers
|
||||
providers = new LinkedList<SocialProvider>();
|
||||
|
|
46
forms/src/main/java/org/keycloak/forms/QRServlet.java
Normal file
|
@ -0,0 +1,46 @@
|
|||
package org.keycloak.forms;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.annotation.WebServlet;
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.jboss.resteasy.logging.Logger;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.client.j2se.MatrixToImageWriter;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import com.google.zxing.qrcode.QRCodeWriter;
|
||||
|
||||
@WebServlet(urlPatterns = "/forms/qrcode")
|
||||
public class QRServlet extends HttpServlet {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final Logger log = Logger.getLogger(QRServlet.class);
|
||||
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
|
||||
String[] size = req.getParameter("size").split("x");
|
||||
int width = Integer.parseInt(size[0]);
|
||||
int height = Integer.parseInt(size[1]);
|
||||
|
||||
String contents = req.getParameter("contents");
|
||||
|
||||
try {
|
||||
QRCodeWriter writer = new QRCodeWriter();
|
||||
|
||||
BitMatrix bitMatrix = writer.encode(contents, BarcodeFormat.QR_CODE, width, height);
|
||||
|
||||
MatrixToImageWriter.writeToStream(bitMatrix, "png", resp.getOutputStream());
|
||||
resp.setContentType("image/png");
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to generate qr code", e);
|
||||
resp.sendError(500);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Before Width: | Height: | Size: 722 B After Width: | Height: | Size: 722 B |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.6 KiB |
Before Width: | Height: | Size: 513 B After Width: | Height: | Size: 513 B |
Before Width: | Height: | Size: 793 B After Width: | Height: | Size: 793 B |
Before Width: | Height: | Size: 343 B After Width: | Height: | Size: 343 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 551 B After Width: | Height: | Size: 551 B |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
@ -29,6 +29,14 @@
|
|||
<label for="password-confirm">#{messages.passwordConfirm}</label>
|
||||
<input type="password" id="password-confirm" name="password-confirm" />
|
||||
</div>
|
||||
|
||||
<h:panelGroup rendered="#{forms.totp}">
|
||||
<div>
|
||||
<label for="totp">#{messages.authenticatorCode}</label>
|
||||
<input type="text" id="totp" name="totp" />
|
||||
<input type="hidden" id="totpSecret" name="totpSecret" value="#{forms.totpSecret}" />
|
||||
</div>
|
||||
</h:panelGroup>
|
||||
|
||||
<div class="aside-btn">
|
||||
<p>By registering you agree to the <a href="#">Terms of Service</a> and the <a href="#">Privacy Policy</a>.</p>
|
||||
|
@ -40,5 +48,11 @@
|
|||
|
||||
<ui:define name="info">
|
||||
<p>#{messages.alreadyHaveAccount} <a href="#{forms.loginUrl}">#{messages.logIn}</a>.</p>
|
||||
|
||||
<h:panelGroup rendered="#{not empty forms.totpSecret}">
|
||||
<p>Google Authenticator setup<br/>
|
||||
<img src="#{forms.totpSecretQrCodeUrl}" />
|
||||
</p>
|
||||
</h:panelGroup>
|
||||
</ui:define>
|
||||
</ui:composition>
|
|
@ -29,7 +29,7 @@
|
|||
<div class="feedback error bottom-left show">
|
||||
<p>
|
||||
<strong>#{forms.error}</strong><br/>
|
||||
#{forms.errorDetails}
|
||||
#{messages[forms.errorDetails]}
|
||||
</p>
|
||||
</div>
|
||||
</h:panelGroup>
|
|
@ -26,7 +26,11 @@ missingName=Please specify full name
|
|||
missingEmail=Please specify email
|
||||
missingUsername=Please specify username
|
||||
missingPassword=Please specify password
|
||||
missingTotp=Please specify authenticator code
|
||||
|
||||
invalidPasswordConfirm=Password confirmation doesn't match
|
||||
invalidTotp=Invalid authenticator code
|
||||
|
||||
usernameExists=Username already exists
|
||||
usernameExists=Username already exists
|
||||
|
||||
error=A system error has occured, contact admin
|
10
pom.xml
|
@ -200,6 +200,16 @@
|
|||
<artifactId>twitter4j-core</artifactId>
|
||||
<version>3.0.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>2.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>javase</artifactId>
|
||||
<version>2.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
|
|
|
@ -42,6 +42,12 @@ public class Messages {
|
|||
|
||||
public static final String MISSING_USERNAME = "missingUsername";
|
||||
|
||||
public static final String MISSING_TOTP = "missingTotp";
|
||||
|
||||
public static final String INVALID_TOTP = "invalidTotp";
|
||||
|
||||
public static final String USERNAME_EXISTS = "usernameExists";
|
||||
|
||||
public static final String ERROR = "error";
|
||||
|
||||
}
|
||||
|
|
|
@ -11,16 +11,20 @@ import org.keycloak.services.managers.AuthenticationManager;
|
|||
import org.keycloak.services.managers.RealmManager;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
import org.keycloak.services.models.RealmModel;
|
||||
import org.keycloak.services.models.RequiredCredentialModel;
|
||||
import org.keycloak.services.models.RoleModel;
|
||||
import org.keycloak.services.models.UserCredentialModel;
|
||||
import org.keycloak.services.models.UserModel;
|
||||
import org.keycloak.services.resources.admin.RealmsAdminResource;
|
||||
import org.keycloak.services.resources.flows.Flows;
|
||||
import org.keycloak.services.validation.Validation;
|
||||
|
||||
import javax.ws.rs.*;
|
||||
import javax.ws.rs.core.*;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
|
@ -308,7 +312,12 @@ public class SaasService {
|
|||
RealmManager realmManager = new RealmManager(session);
|
||||
RealmModel defaultRealm = realmManager.defaultRealm();
|
||||
|
||||
String error = validateRegistrationForm(formData);
|
||||
List<String> requiredCredentialTypes = new LinkedList<String>();
|
||||
for (RequiredCredentialModel m : defaultRealm.getRequiredCredentials()) {
|
||||
requiredCredentialTypes.add(m.getType());
|
||||
}
|
||||
|
||||
String error = Validation.validateRegistrationForm(formData, requiredCredentialTypes);
|
||||
if (error != null) {
|
||||
return Flows.forms(defaultRealm, request).setError(error).setFormData(formData)
|
||||
.forwardToRegistration();
|
||||
|
@ -341,7 +350,15 @@ public class SaasService {
|
|||
newUser.setFirstName(first.toString());
|
||||
newUser.setLastName(last);
|
||||
}
|
||||
newUser.credential(CredentialRepresentation.PASSWORD, formData.getFirst("password"));
|
||||
|
||||
if (requiredCredentialTypes.contains(CredentialRepresentation.PASSWORD)) {
|
||||
newUser.credential(CredentialRepresentation.PASSWORD, formData.getFirst("password"));
|
||||
}
|
||||
|
||||
if (requiredCredentialTypes.contains(CredentialRepresentation.TOTP)) {
|
||||
newUser.credential(CredentialRepresentation.TOTP, formData.getFirst("password"));
|
||||
}
|
||||
|
||||
UserModel user = registerMe(defaultRealm, newUser);
|
||||
if (user == null) {
|
||||
return Flows.forms(defaultRealm, request).setError(Messages.USERNAME_EXISTS)
|
||||
|
@ -384,32 +401,4 @@ public class SaasService {
|
|||
return user;
|
||||
}
|
||||
|
||||
private String validateRegistrationForm(MultivaluedMap<String, String> formData) {
|
||||
if (isEmpty(formData.getFirst("name"))) {
|
||||
return Messages.MISSING_NAME;
|
||||
}
|
||||
|
||||
if (isEmpty(formData.getFirst("email"))) {
|
||||
return Messages.MISSING_EMAIL;
|
||||
}
|
||||
|
||||
if (isEmpty(formData.getFirst("username"))) {
|
||||
return Messages.MISSING_USERNAME;
|
||||
}
|
||||
|
||||
if (isEmpty(formData.getFirst("password"))) {
|
||||
return Messages.MISSING_PASSWORD;
|
||||
}
|
||||
|
||||
if (!formData.getFirst("password").equals(formData.getFirst("password-confirm"))) {
|
||||
return Messages.INVALID_PASSWORD_CONFIRM;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isEmpty(String s) {
|
||||
return s == null || s.length() == 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -19,11 +19,14 @@ import org.keycloak.services.managers.ResourceAdminManager;
|
|||
import org.keycloak.services.managers.TokenManager;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
import org.keycloak.services.models.RealmModel;
|
||||
import org.keycloak.services.models.RequiredCredentialModel;
|
||||
import org.keycloak.services.models.RoleModel;
|
||||
import org.keycloak.services.models.UserCredentialModel;
|
||||
import org.keycloak.services.models.UserModel;
|
||||
import org.keycloak.services.resources.flows.Flows;
|
||||
import org.keycloak.services.resources.flows.OAuthFlows;
|
||||
import org.keycloak.services.validation.Validation;
|
||||
import org.picketlink.idm.credential.util.TimeBasedOTP;
|
||||
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.ForbiddenException;
|
||||
|
@ -46,6 +49,8 @@ import javax.ws.rs.ext.Providers;
|
|||
|
||||
import java.security.PrivateKey;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
|
@ -250,7 +255,12 @@ public class TokenService {
|
|||
return oauth.forwardToSecurityFailure("Registration not allowed");
|
||||
}
|
||||
|
||||
String error = validateRegistrationForm(formData);
|
||||
List<String> requiredCredentialTypes = new LinkedList<String>();
|
||||
for (RequiredCredentialModel m : realm.getRequiredCredentials()) {
|
||||
requiredCredentialTypes.add(m.getType());
|
||||
}
|
||||
|
||||
String error = Validation.validateRegistrationForm(formData, requiredCredentialTypes);
|
||||
if (error != null) {
|
||||
return Flows.forms(realm, request).setError(error).setFormData(formData).forwardToRegistration();
|
||||
}
|
||||
|
@ -291,10 +301,19 @@ public class TokenService {
|
|||
|
||||
user.setEmail(formData.getFirst("email"));
|
||||
|
||||
UserCredentialModel credentials = new UserCredentialModel();
|
||||
credentials.setType(CredentialRepresentation.PASSWORD);
|
||||
credentials.setValue(formData.getFirst("password"));
|
||||
realm.updateCredential(user, credentials);
|
||||
if (requiredCredentialTypes.contains(CredentialRepresentation.PASSWORD)) {
|
||||
UserCredentialModel credentials = new UserCredentialModel();
|
||||
credentials.setType(CredentialRepresentation.PASSWORD);
|
||||
credentials.setValue(formData.getFirst("password"));
|
||||
realm.updateCredential(user, credentials);
|
||||
}
|
||||
|
||||
if (requiredCredentialTypes.contains(CredentialRepresentation.TOTP)) {
|
||||
UserCredentialModel credentials = new UserCredentialModel();
|
||||
credentials.setType(CredentialRepresentation.TOTP);
|
||||
credentials.setValue(formData.getFirst("totpSecret"));
|
||||
realm.updateCredential(user, credentials);
|
||||
}
|
||||
|
||||
for (RoleModel role : realm.getDefaultRoles()) {
|
||||
realm.grantRole(user, role);
|
||||
|
@ -577,32 +596,4 @@ public class TokenService {
|
|||
return location.build();
|
||||
}
|
||||
|
||||
private String validateRegistrationForm(MultivaluedMap<String, String> formData) {
|
||||
if (isEmpty(formData.getFirst("name"))) {
|
||||
return Messages.MISSING_NAME;
|
||||
}
|
||||
|
||||
if (isEmpty(formData.getFirst("email"))) {
|
||||
return Messages.MISSING_EMAIL;
|
||||
}
|
||||
|
||||
if (isEmpty(formData.getFirst("username"))) {
|
||||
return Messages.MISSING_USERNAME;
|
||||
}
|
||||
|
||||
if (isEmpty(formData.getFirst("password"))) {
|
||||
return Messages.MISSING_PASSWORD;
|
||||
}
|
||||
|
||||
if (!formData.getFirst("password").equals(formData.getFirst("password-confirm"))) {
|
||||
return Messages.INVALID_PASSWORD_CONFIRM;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isEmpty(String s) {
|
||||
return s == null || s.length() == 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -26,11 +26,11 @@ package org.keycloak.services.resources.flows;
|
|||
*/
|
||||
public class Pages {
|
||||
|
||||
public final static String LOGIN = "/sdk/login.xhtml";
|
||||
public final static String LOGIN = "/forms/login.xhtml";
|
||||
|
||||
public final static String OAUTH_GRANT = "/saas/oauthGrantForm.jsp";
|
||||
|
||||
public final static String REGISTER = "/sdk/register.xhtml";
|
||||
public final static String REGISTER = "/forms/register.xhtml";
|
||||
|
||||
public final static String SECURITY_FAILURE = "/saas/securityFailure.jsp";
|
||||
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
package org.keycloak.services.validation;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javax.ws.rs.core.MultivaluedMap;
|
||||
|
||||
import org.keycloak.representations.idm.CredentialRepresentation;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
import org.picketlink.idm.credential.util.TimeBasedOTP;
|
||||
|
||||
public class Validation {
|
||||
|
||||
public static String validateRegistrationForm(MultivaluedMap<String, String> formData, List<String> requiredCredentialTypes) {
|
||||
if (isEmpty(formData.getFirst("name"))) {
|
||||
return Messages.MISSING_NAME;
|
||||
}
|
||||
|
||||
if (isEmpty(formData.getFirst("email"))) {
|
||||
return Messages.MISSING_EMAIL;
|
||||
}
|
||||
|
||||
if (isEmpty(formData.getFirst("username"))) {
|
||||
return Messages.MISSING_USERNAME;
|
||||
}
|
||||
|
||||
if (requiredCredentialTypes.contains(CredentialRepresentation.PASSWORD)) {
|
||||
if (isEmpty(formData.getFirst(CredentialRepresentation.PASSWORD))) {
|
||||
return Messages.MISSING_PASSWORD;
|
||||
}
|
||||
|
||||
if (!formData.getFirst("password").equals(formData.getFirst("password-confirm"))) {
|
||||
return Messages.INVALID_PASSWORD_CONFIRM;
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredCredentialTypes.contains(CredentialRepresentation.TOTP)) {
|
||||
if (isEmpty(formData.getFirst("totp"))) {
|
||||
return Messages.MISSING_TOTP;
|
||||
}
|
||||
|
||||
boolean validTotp = new TimeBasedOTP().validate(formData.getFirst("totp"), formData.getFirst("totpSecret")
|
||||
.getBytes());
|
||||
if (!validTotp) {
|
||||
return Messages.INVALID_TOTP;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean isEmpty(String s) {
|
||||
return s == null || s.length() == 0;
|
||||
}
|
||||
|
||||
}
|