/* * The MIT License * * Copyright (c) 2004-2011, Oracle Corporation, Kohsuke Kawaguchi, David Calavera, Seiji Sogabe, Anton Kozak * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ package hudson.security; import com.thoughtworks.xstream.converters.UnmarshallingContext; import hudson.Extension; import hudson.Util; import hudson.diagnosis.OldDataMonitor; import hudson.mail.BaseMailSender; import hudson.model.*; import hudson.security.FederatedLoginService.FederatedIdentity; 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.acegisecurity.Authentication; import org.acegisecurity.AuthenticationException; import org.acegisecurity.BadCredentialsException; import org.acegisecurity.GrantedAuthority; import org.acegisecurity.context.SecurityContextHolder; import org.acegisecurity.providers.UsernamePasswordAuthenticationToken; import org.acegisecurity.providers.encoding.PasswordEncoder; import org.acegisecurity.providers.encoding.ShaPasswordEncoder; import org.acegisecurity.userdetails.UserDetails; import org.acegisecurity.userdetails.UsernameNotFoundException; 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.Collections; import java.util.List; import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED; /** * {@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 final 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, true, false); } @DataBoundConstructor public HudsonPrivateSecurityRealm(boolean allowsSignup, boolean enableCaptcha, boolean notifyUser) { this.disableSignup = !allowsSignup; this.enableCaptcha = enableCaptcha; 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")); 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 = Hudson.getInstance().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(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"; try { Hudson.checkGoodName(si.username); User user = User.get(si.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 = si.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(si.username,si.password1); user.addProperty(new Mailer.UserProperty(si.email)); user.setFullName(si.fullname); user.save(); if (notifyUser && StringUtils.isNotEmpty(si.email)) { notifyUser(si.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. */ 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. */ public String getDisplayName() { return "User Database"; } public ACL getACL() { return Hudson.getInstance().getACL(); } public void checkPermission(Permission permission) { Hudson.getInstance().checkPermission(permission); } public boolean hasPermission(Permission permission) { return Hudson.getInstance().hasPermission(permission); } /** * All users who can login to the system. */ 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. */ 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)); } public GrantedAuthority[] getAuthorities() { // TODO return TEST_AUTHORITY; } 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()); } public String getUsername() { return user.getId(); } /*package*/ User getUser() { return user; } public boolean isAccountNonExpired() { return true; } public boolean isAccountNonLocked() { return true; } public boolean isCredentialsNonExpired() { return true; } public boolean isEnabled() { return true; } 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 { 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 Hudson.getInstance().getSecurityRealm() instanceof HudsonPrivateSecurityRealm; } 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 { public String getIconFileName() { if(Hudson.getInstance().getSecurityRealm() instanceof HudsonPrivateSecurityRealm) return "user.gif"; else return null; // not applicable now } public String getUrlName() { return "securityRealm/"; } 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); public String encodePassword(String rawPass, Object _) throws DataAccessException { return hash(rawPass); } 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> { 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() { public void init(FilterConfig config) throws ServletException { } public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; if(req.getRequestURI().equals(req.getContextPath()+"/")) { 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() && Hudson.getInstance().getSecurityRealm() instanceof HudsonPrivateSecurityRealm; } public void destroy() { } }; }