/*******************************************************************************
*
* 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() {
}
};
}