/******************************************************************************* * * Copyright (c) 2004-2011 Oracle Corporation. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * * Kohsuke Kawaguchi, David Calavera, Seiji Sogabe, Anton Kozak * * *******************************************************************************/ package hudson.security; import hudson.mail.BaseMailSender; import com.thoughtworks.xstream.converters.UnmarshallingContext; import hudson.Extension; import hudson.Functions; import hudson.Util; import hudson.diagnosis.OldDataMonitor; import hudson.model.*; import hudson.security.FederatedLoginService.FederatedIdentity; import org.eclipse.hudson.security.captcha.CaptchaSupport; import hudson.tasks.Mailer; import hudson.util.PluginServletFilter; import hudson.util.Protector; import hudson.util.Scrambler; import hudson.util.XStream2; import net.sf.json.JSONObject; import org.apache.commons.lang3.StringUtils; import org.kohsuke.stapler.*; import org.springframework.dao.DataAccessException; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED; import org.eclipse.hudson.security.HudsonSecurityEntitiesHolder; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.encoding.PasswordEncoder; import org.springframework.security.authentication.encoding.ShaPasswordEncoder; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; /** * {@link SecurityRealm} that performs authentication by looking up * {@link User}. * * <p> Implements {@link AccessControlled} to satisfy view rendering, but in * reality the access control is done against the {@link Hudson} object. * * @author Kohsuke Kawaguchi */ public class HudsonPrivateSecurityRealm extends AbstractPasswordBasedSecurityRealm implements ModelObject, AccessControlled { /** * If true, sign up is not allowed. <p> This is a negative switch so that * the default value 'false' remains compatible with older installations. */ private final boolean disableSignup; /** * If true, captcha will be enabled. */ private boolean enableCaptcha; /** * If true, user will be notified of Hudson account creation. */ private final boolean notifyUser; /** * @deprecated as of 2.0.1 */ @Deprecated public HudsonPrivateSecurityRealm(boolean allowsSignup) { this(allowsSignup, true); } /** * @deprecated as of 2.2.0 */ @Deprecated public HudsonPrivateSecurityRealm(boolean allowsSignup, boolean enableCaptcha) { this(allowsSignup, enableCaptcha, null, false); } @DataBoundConstructor public HudsonPrivateSecurityRealm(boolean allowsSignup, boolean enableCaptcha, CaptchaSupport captchaSupport, boolean notifyUser) { this.disableSignup = !allowsSignup; this.enableCaptcha = enableCaptcha; if (captchaSupport != null){ setCaptchaSupport(captchaSupport); }else{ this.enableCaptcha = false; } this.notifyUser = notifyUser; if (!allowsSignup && !hasSomeUser()) { // if Hudson is newly set up with the security realm and there's no user account created yet, // insert a filter that asks the user to create one try { PluginServletFilter.addFilter(CREATE_FIRST_USER_FILTER); } catch (ServletException e) { throw new AssertionError(e); // never happen because our Filter.init is no-op } } } @Override public boolean allowsSignup() { return !disableSignup; } /** * Checks if captcha is enabled on signup. * * @return true if captcha is enabled on signup. */ public boolean isEnableCaptcha() { return enableCaptcha; } /** * Returns true if Hudson should notify user of account creation. * * @return true if Hudson should notify user of account creation. */ public boolean isNotifyUser() { return notifyUser; } /** * Computes if this Hudson has some user accounts configured. * * <p> This is used to check for the initial */ private static boolean hasSomeUser() { for (User u : User.getAll()) { if (u.getProperty(Details.class) != null) { return true; } } return false; } /** * This implementation doesn't support groups. */ @Override public GroupDetails loadGroupByGroupname(String groupname) throws UsernameNotFoundException, DataAccessException { throw new UsernameNotFoundException(groupname); } @Override public Details loadUserByUsername(String username) throws UsernameNotFoundException, DataAccessException { User u = User.get(username, false); Details p = u != null ? u.getProperty(Details.class) : null; if (p == null) { throw new UsernameNotFoundException("Password is not set: " + username); } if (p.getUser() == null) { throw new AssertionError(); } return p; } @Override protected Details authenticate(String username, String password) throws AuthenticationException { Details u = loadUserByUsername(username); if (!PASSWORD_ENCODER.isPasswordValid(u.getPassword(), password, null)) { throw new BadCredentialsException("Failed to login as " + username); } return u; } /** * Show the sign up page with the data from the identity. */ @Override public HttpResponse commenceSignup(final FederatedIdentity identity) { // store the identity in the session so that we can use this later Stapler.getCurrentRequest().getSession().setAttribute(FEDERATED_IDENTITY_SESSION_KEY, identity); return new ForwardToView(this, "signupWithFederatedIdentity.jelly") { @Override public void generateResponse(StaplerRequest req, StaplerResponse rsp, Object node) throws IOException, ServletException { SignupInfo si = new SignupInfo(identity); si.errorMessage = Messages.HudsonPrivateSecurityRealm_WouldYouLikeToSignUp(identity.getPronoun(), identity.getIdentifier()); req.setAttribute("data", si); super.generateResponse(req, rsp, node); } }; } /** * Creates an account and associates that with the given identity. Used in * conjunction with {@link #commenceSignup(FederatedIdentity)}. */ public User doCreateAccountWithFederatedIdentity(StaplerRequest req, StaplerResponse rsp) throws IOException, ServletException { User u = _doCreateAccount(req, rsp, "signupWithFederatedIdentity.jelly"); if (u != null) { ((FederatedIdentity) req.getSession().getAttribute(FEDERATED_IDENTITY_SESSION_KEY)).addTo(u); } return u; } private static final String FEDERATED_IDENTITY_SESSION_KEY = HudsonPrivateSecurityRealm.class.getName() + ".federatedIdentity"; /** * Creates an user account. Used for self-registration. */ public User doCreateAccount(StaplerRequest req, StaplerResponse rsp) throws IOException, ServletException { return _doCreateAccount(req, rsp, "signup.jelly"); } private User _doCreateAccount(StaplerRequest req, StaplerResponse rsp, String formView) throws ServletException, IOException { if (!allowsSignup()) { throw HttpResponses.error(SC_UNAUTHORIZED, new Exception("User sign up is prohibited")); } boolean firstUser = !hasSomeUser(); User u = createAccount(req, rsp, enableCaptcha, formView); if (u != null) { if (firstUser) { tryToMakeAdmin(u); // the first user should be admin, or else there's a risk of lock out } loginAndTakeBack(req, rsp, u); } return u; } /** * Lets the current user silently login as the given user and report back * accordingly. */ private void loginAndTakeBack(StaplerRequest req, StaplerResponse rsp, User u) throws ServletException, IOException { // ... and let him login Authentication a = new UsernamePasswordAuthenticationToken(u.getId(), req.getParameter("password1"), ACL.NO_AUTHORITIES); a = this.getSecurityComponents().manager.authenticate(a); SecurityContextHolder.getContext().setAuthentication(a); // then back to top req.getView(this, "success.jelly").forward(req, rsp); } /** * Creates an user account. Used by admins. * * This version behaves differently from * {@link #doCreateAccount(StaplerRequest, StaplerResponse)} in that this is * someone creating another user. */ public void doCreateAccountByAdmin(StaplerRequest req, StaplerResponse rsp) throws IOException, ServletException { checkPermission(Hudson.ADMINISTER); if (createAccount(req, rsp, false, "addUser.jelly") != null) { rsp.sendRedirect("."); // send the user back to the listing page } } /** * Creates a first admin user account. * * <p> This can be run by anyone, but only to create the very first user * account. */ public void doCreateFirstAccount(StaplerRequest req, StaplerResponse rsp) throws IOException, ServletException { if (hasSomeUser()) { rsp.sendError(SC_UNAUTHORIZED, "First user was already created"); return; } User u = createAccount(req, rsp, false, "firstUser.jelly"); if (u != null) { tryToMakeAdmin(u); loginAndTakeBack(req, rsp, u); } } /** * Try to make this user a super-user */ private void tryToMakeAdmin(User u) { AuthorizationStrategy as = HudsonSecurityEntitiesHolder.getHudsonSecurityManager().getAuthorizationStrategy(); if (as instanceof GlobalMatrixAuthorizationStrategy) { GlobalMatrixAuthorizationStrategy ma = (GlobalMatrixAuthorizationStrategy) as; ma.add(Hudson.ADMINISTER, u.getId()); } } /** * @return null if failed. The browser is already redirected to retry by the * time this method returns. a valid {@link User} object if the user * creation was successful. */ private User createAccount(StaplerRequest req, StaplerResponse rsp, boolean selfRegistration, String formView) throws ServletException, IOException { // form field validation // this pattern needs to be generalized and moved to stapler SignupInfo si = new SignupInfo(req); if ((si.username == null) || (si.username.trim().length() == 0)) { si.errorMessage = "User name required!"; req.setAttribute("data", si); req.getView(this, formView).forward(req, rsp); return null; } if (selfRegistration && !validateCaptcha(si.captcha)) { si.errorMessage = "Text didn't match the word shown in the image"; } if (si.password1 != null && !si.password1.equals(si.password2)) { si.errorMessage = "Password didn't match"; } if (!(si.password1 != null && si.password1.length() != 0)) { si.errorMessage = "Password is required"; } String username = si.username.trim(); try { Hudson.checkGoodName(username); User user = User.get(username); if (user.getProperty(Details.class) != null) { si.errorMessage = "User name is already taken. Did you forget the password?"; } } catch (Failure e) { si.errorMessage = "User name is not valid: " + e.getMessage(); } if (si.fullname == null || si.fullname.length() == 0) { si.fullname = username; } if (si.email == null || !si.email.contains("@")) { si.errorMessage = "Invalid e-mail address"; } if (si.errorMessage != null) { // failed. ask the user to try again. req.setAttribute("data", si); req.getView(this, formView).forward(req, rsp); return null; } // register the user final User user = createAccount(username, si.password1); user.addProperty(new Mailer.UserProperty(si.email)); user.setFullName(si.fullname); user.save(); if (notifyUser && StringUtils.isNotEmpty(si.email)) { notifyUser(username, si.email, si.fullname, si.password1); } return user; } private void notifyUser(final String username, final String email, final String fullname, final String passwd) { new BaseMailSender(email) { @Override protected String getText() { String baseUrl = Mailer.descriptor().getUrl(); return hudson.mail.Messages .account_creation_email_text(fullname != null ? fullname : "", baseUrl, email, username, passwd); } @Override protected String getSubject() { return hudson.mail.Messages.account_creation_email_subject(); } }.execute(); } /** * Creates a new user account by registering a password to the user. * @param userName * @param password * @return * @throws java.io.IOException */ public User createAccount(String userName, String password) throws IOException { User user = User.get(userName); user.addProperty(Details.fromPlainPassword(password)); return user; } /** * This is used primarily when the object is listed in the breadcrumb, in * the user management screen. * @return */ @Override public String getDisplayName() { return "User Database"; } @Override public ACL getACL() { return HudsonSecurityEntitiesHolder.getHudsonSecurityManager().getACL(); } @Override public void checkPermission(Permission permission) { HudsonSecurityEntitiesHolder.getHudsonSecurityManager().checkPermission(permission); } @Override public boolean hasPermission(Permission permission) { return HudsonSecurityEntitiesHolder.getHudsonSecurityManager().hasPermission(permission); } /** * All users who can login to the system. * @return */ public List<User> getAllUsers() { List<User> r = new ArrayList<User>(); for (User u : User.getAll()) { if (u.getProperty(Details.class) != null) { r.add(u); } } Collections.sort(r); return r; } /** * This is to map users under the security realm URL. This in turn helps us * set up the right navigation breadcrumb. * @param id * @return */ public User getUser(String id) { return User.get(id); } // TODO private static final GrantedAuthority[] TEST_AUTHORITY = {AUTHENTICATED_AUTHORITY}; public static final class SignupInfo { //TODO: review and check whether we can do it private public String username, password1, password2, fullname, email, captcha; /** * To display an error message, set it here. */ public String errorMessage; public SignupInfo() { } public SignupInfo(StaplerRequest req) { req.bindParameters(this); } public SignupInfo(FederatedIdentity i) { this.username = i.getNickname(); this.fullname = i.getFullName(); this.email = i.getEmailAddress(); } public String getUsername() { return username; } public String getPassword1() { return password1; } public String getPassword2() { return password2; } public String getFullname() { return fullname; } public String getEmail() { return email; } public String getCaptcha() { return captcha; } public String getErrorMessage() { return errorMessage; } } /** * {@link UserProperty} that provides the {@link UserDetails} view of the * User object. * * <p> When a {@link User} object has this property on it, it means the user * is configured for log-in. * * <p> When a {@link User} object is re-configured via the UI, the password * is sent to the hidden input field by using {@link Protector}, so that the * same password can be retained but without leaking information to the * browser. */ public static final class Details extends UserProperty implements InvalidatableUserDetails { /** * Hashed password. */ private /*almost final*/ String passwordHash; /** * @deprecated Scrambled password. Field kept here to load old (pre * 1.283) user records, but now marked transient so field is no longer * saved. */ private transient String password; private Details(String passwordHash) { this.passwordHash = passwordHash; } static Details fromHashedPassword(String hashed) { return new Details(hashed); } static Details fromPlainPassword(String rawPassword) { return new Details(PASSWORD_ENCODER.encodePassword(rawPassword, null)); } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return Arrays.asList(TEST_AUTHORITY); } @Override public String getPassword() { return passwordHash; } public String getProtectedPassword() { // put session Id in it to prevent a replay attack. return Protector.protect(Stapler.getCurrentRequest().getSession().getId() + ':' + getPassword()); } @Override public String getUsername() { return user.getId(); } /*package*/ User getUser() { return user; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } @Override public boolean isInvalid() { return user == null; } public static class ConverterImpl extends XStream2.PassthruConverter<Details> { public ConverterImpl(XStream2 xstream) { super(xstream); } @Override protected void callback(Details d, UnmarshallingContext context) { // Convert to hashed password and report to monitor if we load old data if (d.password != null && d.passwordHash == null) { d.passwordHash = PASSWORD_ENCODER.encodePassword(Scrambler.descramble(d.password), null); OldDataMonitor.report(context, "1.283"); } } } @Extension public static final class DescriptorImpl extends UserPropertyDescriptor { @Override public String getDisplayName() { // this feature is only when HudsonPrivateSecurityRealm is enabled if (isEnabled()) { return Messages.HudsonPrivateSecurityRealm_Details_DisplayName(); } else { return null; } } @Override public Details newInstance(StaplerRequest req, JSONObject formData) throws FormException { String pwd = Util.fixEmpty(req.getParameter("user.password")); String pwd2 = Util.fixEmpty(req.getParameter("user.password2")); if (!Util.fixNull(pwd).equals(Util.fixNull(pwd2))) { throw new FormException("Please confirm the password by typing it twice", "user.password2"); } String data = Protector.unprotect(pwd); if (data != null) { String prefix = Stapler.getCurrentRequest().getSession().getId() + ':'; if (data.startsWith(prefix)) { return Details.fromHashedPassword(data.substring(prefix.length())); } } return Details.fromPlainPassword(Util.fixNull(pwd)); } @Override public boolean isEnabled() { return HudsonSecurityEntitiesHolder.getHudsonSecurityManager().getSecurityRealm() instanceof HudsonPrivateSecurityRealm; } @Override public UserProperty newInstance(User user) { return null; } } } /** * Displays "manage users" link in the system config if * {@link HudsonPrivateSecurityRealm} is in effect. */ @Extension public static final class ManageUserLinks extends ManagementLink { @Override public String getIconFileName() { if (HudsonSecurityEntitiesHolder.getHudsonSecurityManager().getSecurityRealm() instanceof HudsonPrivateSecurityRealm) { return "user.png"; } else { return null; // not applicable now } } @Override public String getUrlName() { return "securityRealm/"; } @Override public String getDisplayName() { return Messages.HudsonPrivateSecurityRealm_ManageUserLinks_DisplayName(); } @Override public String getDescription() { return Messages.HudsonPrivateSecurityRealm_ManageUserLinks_Description(); } } /** * {@link PasswordEncoder} based on SHA-256 and random salt generation. * * <p> The salt is prepended to the hashed password and returned. So the * encoded password is of the form <tt>SALT ':' hash(PASSWORD,SALT)</tt>. * * <p> This abbreviates the need to store the salt separately, which in turn * allows us to hide the salt handling in this little class. The rest of the * Acegi thinks that we are not using salt. */ public static final PasswordEncoder PASSWORD_ENCODER = new PasswordEncoder() { private final PasswordEncoder passwordEncoder = new ShaPasswordEncoder(256); @Override public String encodePassword(String rawPass, Object _) throws DataAccessException { return hash(rawPass); } @Override public boolean isPasswordValid(String encPass, String rawPass, Object _) throws DataAccessException { // pull out the sale from the encoded password int i = encPass.indexOf(':'); if (i < 0) { return false; } String salt = encPass.substring(0, i); return encPass.substring(i + 1).equals(passwordEncoder.encodePassword(rawPass, salt)); } /** * Creates a hashed password by generating a random salt. */ private String hash(String password) { String salt = generateSalt(); return salt + ':' + passwordEncoder.encodePassword(password, salt); } /** * Generates random salt. */ private String generateSalt() { StringBuilder buf = new StringBuilder(); SecureRandom sr = new SecureRandom(); for (int i = 0; i < 6; i++) { // log2(52^6)=34.20... so, this is about 32bit strong. boolean upper = sr.nextBoolean(); char ch = (char) (sr.nextInt(26) + 'a'); if (upper) { ch = Character.toUpperCase(ch); } buf.append(ch); } return buf.toString(); } }; @Extension public static final class DescriptorImpl extends Descriptor<SecurityRealm> { @Override public String getDisplayName() { return Messages.HudsonPrivateSecurityRealm_DisplayName(); } @Override public String getHelpFile() { return "/help/security/private-realm.html"; } } private static final Filter CREATE_FIRST_USER_FILTER = new Filter() { @Override public void init(FilterConfig config) throws ServletException { } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; if (req.getRequestURI().equals(Functions.getHttpRequestRootPath(req) + "/")) { if (needsToCreateFirstUser()) { ((HttpServletResponse) response).sendRedirect("securityRealm/firstUser"); } else { // the first user already created. the role of this filter is over. PluginServletFilter.removeFilter(this); chain.doFilter(request, response); } } else { chain.doFilter(request, response); } } private boolean needsToCreateFirstUser() { return !hasSomeUser() && HudsonSecurityEntitiesHolder.getHudsonSecurityManager().getSecurityRealm() instanceof HudsonPrivateSecurityRealm; } @Override public void destroy() { } }; }