feat: multiple access token support with optional expiration (#568)

This commit is contained in:
Robin Shen 2023-05-29 11:24:37 +08:00
parent 98a22fc621
commit 1a139bb783
29 changed files with 560 additions and 150 deletions

View File

@ -82,7 +82,9 @@ public interface UserManager extends EntityManager<User> {
User findByFullName(String fullName);
@Nullable
User findByAccessToken(String accessToken);
User findByAccessToken(String accessTokenValue);
String createTemporalAccessToken(Long userId, long secondsToExpire);
@Nullable
User findByVerifiedEmailAddress(String emailAddress);

View File

@ -3,6 +3,7 @@ package io.onedev.server.entitymanager.impl;
import com.google.common.base.Preconditions;
import com.google.common.collect.Sets;
import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.map.IMap;
import io.onedev.server.cluster.ClusterManager;
import io.onedev.server.entitymanager.*;
import io.onedev.server.event.Listen;
@ -36,6 +37,7 @@ import javax.inject.Singleton;
import javax.persistence.criteria.*;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static io.onedev.server.model.User.*;
@ -62,6 +64,8 @@ public class DefaultUserManager extends BaseEntityManager<User> implements UserM
private volatile UserCache cache;
private volatile IMap<String, Long> temporalAccessTokens;
@Inject
public DefaultUserManager(Dao dao, ProjectManager projectManager, SettingManager settingManager,
IssueFieldManager issueFieldManager, IdManager idManager,
@ -294,18 +298,30 @@ public class DefaultUserManager extends BaseEntityManager<User> implements UserM
@Sessional
@Override
public User findByAccessToken(String accessToken) {
public User findByAccessToken(String accessTokenValue) {
if (cache != null) {
UserFacade facade = cache.findByAccessToken(accessToken);
if (facade != null)
UserFacade facade = cache.findByAccessToken(accessTokenValue);
if (facade != null) {
return load(facade.getId());
else
return null;
} else {
Long userId = temporalAccessTokens.get(accessTokenValue);
if (userId != null)
return load(userId);
else
return null;
}
} else {
throw new ServerNotReadyException();
}
}
@Override
public String createTemporalAccessToken(Long userId, long secondsToExpire) {
var value = CryptoUtils.generateSecret();
temporalAccessTokens.put(value, userId, secondsToExpire, TimeUnit.SECONDS);
return value;
}
@Override
public List<User> query() {
EntityCriteria<User> criteria = newCriteria();
@ -323,6 +339,7 @@ public class DefaultUserManager extends BaseEntityManager<User> implements UserM
@Listen
public void on(SystemStarting event) {
HazelcastInstance hazelcastInstance = clusterManager.getHazelcastInstance();
temporalAccessTokens = hazelcastInstance.getMap("temporalAccessTokens");
cache = new UserCache(hazelcastInstance.getMap("userCache"));
var cacheInited = hazelcastInstance.getCPSubsystem().getAtomicLong("userCacheInited");
clusterManager.init(cacheInited, () -> {

View File

@ -4,10 +4,12 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.hash.HashingInputStream;
import io.onedev.k8shelper.KubernetesHelper;
import io.onedev.server.OneDev;
import io.onedev.server.cluster.ClusterManager;
import io.onedev.server.entitymanager.GitLfsLockManager;
import io.onedev.server.entitymanager.ProjectManager;
import io.onedev.server.entitymanager.SettingManager;
import io.onedev.server.entitymanager.UserManager;
import io.onedev.server.model.GitLfsLock;
import io.onedev.server.model.Project;
import io.onedev.server.model.User;
@ -285,8 +287,9 @@ public class GitLfsFilter implements Filter {
httpResponse.setContentType(CONTENT_TYPE);
if (pathInfo.endsWith("/batch")) {
String accessToken;
if (SecurityUtils.getUser() != null)
accessToken = SecurityUtils.getUser().getAccessToken();
var user = SecurityUtils.getUser();
if (user != null)
accessToken = OneDev.getInstance(UserManager.class).createTemporalAccessToken(user.getId(), 300);
else
accessToken = null;
processBatch(httpRequest, httpResponse, project.getFacade(),

View File

@ -91,36 +91,31 @@ public class LfsAuthenticateCommand implements Command, ServerSessionAware {
public void start(ChannelSession channel, Environment env) throws IOException {
SshAuthenticator authenticator = OneDev.getInstance(SshAuthenticator.class);
Long userId = authenticator.getPublicKeyOwnerId(session);
OneDev.getInstance(ExecutorService.class).submit(new Runnable() {
@Override
public void run() {
SessionManager sessionManager = OneDev.getInstance(SessionManager.class);
sessionManager.openSession();
try {
String accessToken = OneDev.getInstance(UserManager.class).load(userId).getAccessToken();
String projectPath = StringUtils.strip(StringUtils.substringBefore(
commandString.substring(COMMAND_PREFIX.length()+1), " "), "/\\");
Project project = OneDev.getInstance(ProjectManager.class).findByPath(projectPath);
if (project == null)
throw new ExplicitException("Project not found: " + projectPath);
String url = OneDev.getInstance(UrlManager.class).cloneUrlFor(project, false);
Map<Object, Object> response = CollectionUtils.newHashMap(
"href", url + ".git/info/lfs",
"header", CollectionUtils.newHashMap(
"Authorization", KubernetesHelper.BEARER + " " + accessToken));
out.write(OneDev.getInstance(ObjectMapper.class).writeValueAsBytes(response));
callback.onExit(0);
} catch (Exception e) {
logger.error("Error executing " + COMMAND_PREFIX, e);
new PrintStream(err).println("Check server log for details");
callback.onExit(-1);
} finally {
sessionManager.closeSession();
}
OneDev.getInstance(ExecutorService.class).submit(() -> {
SessionManager sessionManager = OneDev.getInstance(SessionManager.class);
sessionManager.openSession();
try {
String accessToken = OneDev.getInstance(UserManager.class).createTemporalAccessToken(userId, 300);
String projectPath = StringUtils.strip(StringUtils.substringBefore(
commandString.substring(COMMAND_PREFIX.length()+1), " "), "/\\");
Project project = OneDev.getInstance(ProjectManager.class).findByPath(projectPath);
if (project == null)
throw new ExplicitException("Project not found: " + projectPath);
String url = OneDev.getInstance(UrlManager.class).cloneUrlFor(project, false);
Map<Object, Object> response = CollectionUtils.newHashMap(
"href", url + ".git/info/lfs",
"header", CollectionUtils.newHashMap(
"Authorization", KubernetesHelper.BEARER + " " + accessToken));
out.write(OneDev.getInstance(ObjectMapper.class).writeValueAsBytes(response));
callback.onExit(0);
} catch (Exception e) {
logger.error("Error executing " + COMMAND_PREFIX, e);
new PrintStream(err).println("Check server log for details");
callback.onExit(-1);
} finally {
sessionManager.closeSession();
}
});
});
}
@Override

View File

@ -5396,4 +5396,21 @@ public class DataMigrator {
codeCommentTouchesDoc.writeToFile(new File(dataDir, "CodeCommentTouchs.xml"), true);
}
private void migrate124(File dataDir, Stack<Integer> versions) {
for (File file: dataDir.listFiles()) {
if (file.getName().startsWith("Users.xml")) {
VersionedXmlDoc dom = VersionedXmlDoc.fromFile(file);
for (Element element: dom.getRootElement().elements()) {
var accessTokenElement = element.element("accessToken");
var accessTokensElement = element.addElement("accessTokens");
var newAccessTokenElement = accessTokensElement.addElement("io.onedev.server.model.support.AccessToken");
newAccessTokenElement.addElement("value").setText(accessTokenElement.getText().trim());
newAccessTokenElement.addElement("createDate").setText("2023-05-28T22:07:56.311+01:00");
accessTokenElement.detach();
}
dom.writeToFile(file, false);
}
}
}
}

View File

@ -89,8 +89,8 @@ public class EmailAddress extends AbstractEntity {
@Override
public EmailAddressFacade getFacade() {
return new EmailAddressFacade(getId(), getValue(), isPrimary(), isGit(),
getVerificationCode(), getOwner().getId());
return new EmailAddressFacade(getId(), getOwner().getId(), getValue(),
isPrimary(), isGit(), getVerificationCode());
}
}

View File

@ -11,6 +11,7 @@ import io.onedev.server.annotation.UserName;
import io.onedev.server.entitymanager.EmailAddressManager;
import io.onedev.server.entitymanager.SettingManager;
import io.onedev.server.entitymanager.UserManager;
import io.onedev.server.model.support.AccessToken;
import io.onedev.server.model.support.NamedProjectQuery;
import io.onedev.server.model.support.QueryPersonalization;
import io.onedev.server.model.support.TwoFactorAuthentication;
@ -20,7 +21,6 @@ import io.onedev.server.model.support.build.NamedBuildQuery;
import io.onedev.server.model.support.issue.NamedIssueQuery;
import io.onedev.server.model.support.pullrequest.NamedPullRequestQuery;
import io.onedev.server.security.SecurityUtils;
import io.onedev.server.util.CryptoUtils;
import io.onedev.server.util.facade.UserFacade;
import io.onedev.server.util.watch.QuerySubscriptionSupport;
import io.onedev.server.util.watch.QueryWatchSupport;
@ -43,7 +43,7 @@ import static io.onedev.server.model.User.*;
@Entity
@Table(
indexes={@Index(columnList=PROP_NAME), @Index(columnList=PROP_FULL_NAME),
@Index(columnList=PROP_SSO_CONNECTOR), @Index(columnList=PROP_ACCESS_TOKEN)})
@Index(columnList=PROP_SSO_CONNECTOR)})
@Cache(usage=CacheConcurrencyStrategy.READ_WRITE)
@Editable
public class User extends AbstractEntity implements AuthenticationInfo {
@ -94,10 +94,11 @@ public class User extends AbstractEntity implements AuthenticationInfo {
@JsonIgnore
private String ssoConnector;
@Column(unique=true, nullable=false)
@JsonIgnore
private String accessToken = CryptoUtils.generateSecret();
@Lob
@Column(length=65535)
private ArrayList<AccessToken> accessTokens = new ArrayList<>();
@JsonIgnore
@Lob
@ -477,12 +478,12 @@ public class User extends AbstractEntity implements AuthenticationInfo {
this.ssoConnector = ssoConnector;
}
public String getAccessToken() {
return accessToken;
public ArrayList<AccessToken> getAccessTokens() {
return accessTokens;
}
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
public void setAccessTokens(ArrayList<AccessToken> accessTokens) {
this.accessTokens = accessTokens;
}
@Nullable
@ -850,7 +851,7 @@ public class User extends AbstractEntity implements AuthenticationInfo {
@Override
public UserFacade getFacade() {
return new UserFacade(getId(), getName(), getFullName(), getAccessToken());
return new UserFacade(getId(), getName(), getFullName(), getAccessTokens());
}
}

View File

@ -0,0 +1,87 @@
package io.onedev.server.model.support;
import io.onedev.server.annotation.Editable;
import io.onedev.server.annotation.Multiline;
import io.onedev.server.model.AbstractEntity;
import io.onedev.server.model.User;
import io.onedev.server.util.CryptoUtils;
import io.onedev.server.util.facade.AccessTokenFacade;
import org.apache.wicket.markup.html.basic.Label;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
import javax.annotation.Nullable;
import javax.persistence.*;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
import java.io.Serializable;
import java.util.Date;
@Editable
public class AccessToken implements Serializable {
private static final long serialVersionUID = 1L;
private String value = CryptoUtils.generateSecret();
private String description;
private Date createDate = new Date();
private Date expireDate;
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
@Editable(order=100, description = "Optionally specify description of the token")
@Multiline
@Nullable
public String getDescription() {
return description;
}
public void setDescription(@Nullable String description) {
this.description = description;
}
public Date getCreateDate() {
return createDate;
}
public void setCreateDate(Date createDate) {
this.createDate = createDate;
}
@Editable(order=200, placeholder = "Never expire", description = "Optionally specify " +
"expiration date of the token. Leave empty to never expire")
@Nullable
public Date getExpireDate() {
return expireDate;
}
public void setExpireDate(@Nullable Date expireDate) {
this.expireDate = expireDate;
}
public boolean isExpired() {
return getExpireDate() != null && getExpireDate().before(new Date());
}
public String getMaskedValue() {
var maskedValue = new StringBuilder();
for (int i=0; i<value.length(); i++) {
var ch = value.charAt(i);
if (i >= 6)
maskedValue.append("*");
else
maskedValue.append(ch);
}
return maskedValue.toString();
}
}

View File

@ -23,6 +23,7 @@ import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import io.onedev.server.model.support.AccessToken;
import org.apache.shiro.authc.credential.PasswordService;
import org.apache.shiro.authz.UnauthenticatedException;
import org.apache.shiro.authz.UnauthorizedException;
@ -104,13 +105,13 @@ public class UserResource {
}
@Api(order=250)
@Path("/{userId}/access-token")
@Path("/{userId}/access-tokens")
@GET
public String getAccessToken(@PathParam("userId") Long userId) {
public List<AccessToken> getAccessTokens(@PathParam("userId") Long userId) {
User user = userManager.load(userId);
if (!SecurityUtils.isAdministrator() && !user.equals(SecurityUtils.getUser()))
throw new UnauthorizedException();
return user.getAccessToken();
return user.getAccessTokens();
}
@Api(order=275)

View File

@ -0,0 +1,25 @@
package io.onedev.server.util.facade;
public class AccessTokenFacade extends EntityFacade {
private static final long serialVersionUID = 1L;
private final Long ownerId;
private final String value;
public AccessTokenFacade(Long id, Long ownerId, String value) {
super(id);
this.ownerId = ownerId;
this.value = value;
}
public Long getOwnerId() {
return ownerId;
}
public String getValue() {
return value;
}
}

View File

@ -5,7 +5,9 @@ import javax.annotation.Nullable;
public class EmailAddressFacade extends EntityFacade {
private static final long serialVersionUID = 1L;
private final Long ownerId;
private final String value;
private final boolean primary;
@ -14,16 +16,18 @@ public class EmailAddressFacade extends EntityFacade {
private final String verificationCode;
private final Long ownerId;
public EmailAddressFacade(Long id, String value, boolean primary, boolean git,
@Nullable String verificationCode, Long ownerId) {
public EmailAddressFacade(Long id, Long ownerId, String value, boolean primary,
boolean git, @Nullable String verificationCode) {
super(id);
this.ownerId = ownerId;
this.value = value;
this.primary = primary;
this.git = git;
this.verificationCode = verificationCode;
this.ownerId = ownerId;
}
public Long getOwnerId() {
return ownerId;
}
public String getValue() {
@ -43,10 +47,6 @@ public class EmailAddressFacade extends EntityFacade {
return verificationCode;
}
public Long getOwnerId() {
return ownerId;
}
public boolean isVerified() {
return getVerificationCode() == null;
}

View File

@ -43,10 +43,12 @@ public class UserCache extends MapProxy<Long, UserFacade> implements Serializabl
}
@Nullable
public UserFacade findByAccessToken(String accessToken) {
public UserFacade findByAccessToken(String accessTokenValue) {
for (UserFacade facade: values()) {
if (accessToken.equals(facade.getAccessToken()))
return facade;
for (var accessToken: facade.getAccessTokens()) {
if (accessTokenValue.equals(accessToken.getValue()) && !accessToken.isExpired())
return facade;
}
}
return null;
}

View File

@ -1,6 +1,9 @@
package io.onedev.server.util.facade;
import io.onedev.server.model.support.AccessToken;
import javax.annotation.Nullable;
import java.util.List;
public class UserFacade extends EntityFacade {
@ -10,13 +13,13 @@ public class UserFacade extends EntityFacade {
private final String fullName;
private final String accessToken;
private final List<AccessToken> accessTokens;
public UserFacade(Long id, String name, @Nullable String fullName, String accessToken) {
public UserFacade(Long id, String name, @Nullable String fullName, List<AccessToken> accessTokens) {
super(id);
this.name = name;
this.fullName = fullName;
this.accessToken = accessToken;
this.accessTokens = accessTokens;
}
public String getName() {
@ -27,8 +30,8 @@ public class UserFacade extends EntityFacade {
return fullName;
}
public String getAccessToken() {
return accessToken;
public List<AccessToken> getAccessTokens() {
return accessTokens;
}
public String getDisplayName() {

View File

@ -0,0 +1,14 @@
<wicket:panel>
<form wicket:id="form" class="leave-confirm border rounded">
<div class="border-bottom px-4 py-3">
<span wicket:id="value" class="font-weight-bolder text-monospace"></span>
</div>
<div class="p-4">
<div wicket:id="editor"></div>
<div class="actions">
<button wicket:id="save" class="btn btn-primary">Save</button> &nbsp;
<button wicket:id="cancel" class="btn btn-secondary">Cancel</button>
</div>
</div>
</form>
</wicket:panel>

View File

@ -0,0 +1,63 @@
package io.onedev.server.web.component.user.accesstoken;
import io.onedev.server.model.support.AccessToken;
import io.onedev.server.web.editable.BeanContext;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.markup.html.AjaxLink;
import org.apache.wicket.ajax.markup.html.form.AjaxButton;
import org.apache.wicket.feedback.FencedFeedbackPanel;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.form.Form;
import org.apache.wicket.markup.html.panel.Panel;
@SuppressWarnings("serial")
abstract class AccessTokenEditPanel extends Panel {
private final AccessToken accessToken;
public AccessTokenEditPanel(String id, AccessToken accessToken) {
super(id);
this.accessToken = accessToken;
}
@Override
protected void onInitialize() {
super.onInitialize();
Form<?> form = new Form<Void>("form");
form.add(new Label("value", accessToken.getMaskedValue()));
form.add(BeanContext.edit("editor", accessToken));
form.add(new AjaxButton("save") {
@Override
protected void onSubmit(AjaxRequestTarget target, Form<?> form) {
super.onSubmit(target, form);
onSave(target, accessToken);
}
@Override
protected void onError(AjaxRequestTarget target, Form<?> form) {
super.onError(target, form);
target.add(form);
}
});
form.add(new AjaxLink<Void>("cancel") {
@Override
public void onClick(AjaxRequestTarget target) {
onCancel(target);
}
});
add(form);
setOutputMarkupId(true);
}
protected abstract void onSave(AjaxRequestTarget target, AccessToken accessToken);
protected abstract void onCancel(AjaxRequestTarget target);
}

View File

@ -0,0 +1,11 @@
<wicket:panel>
<div wicket:id="accessTokens" class="access-tokens">
<ul class="list-unstyled">
<li wicket:id="accessTokens" class="access-token mb-5"><div wicket:id="accessToken"></div></li>
</ul>
<div wicket:id="newAccessToken"></div>
</div>
<wicket:fragment wicket:id="addNewLinkFrag">
<a wicket:id="link" class="btn btn-block btn-light"><wicket:svg href="plus" class="icon align-middle mr-1"></wicket:svg> <span>Create New</span></a>
</wicket:fragment>
</wicket:panel>

View File

@ -0,0 +1,132 @@
package io.onedev.server.web.component.user.accesstoken;
import io.onedev.server.OneDev;
import io.onedev.server.entitymanager.UserManager;
import io.onedev.server.model.User;
import io.onedev.server.model.support.AccessToken;
import org.apache.wicket.Component;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.markup.html.AjaxLink;
import org.apache.wicket.markup.html.WebMarkupContainer;
import org.apache.wicket.markup.html.list.ListItem;
import org.apache.wicket.markup.html.list.ListView;
import org.apache.wicket.markup.html.panel.Fragment;
import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.model.AbstractReadOnlyModel;
import java.util.List;
@SuppressWarnings("serial")
public abstract class AccessTokenListPanel extends Panel {
private WebMarkupContainer container;
public AccessTokenListPanel(String id) {
super(id);
}
protected abstract User getUser();
@Override
protected void onInitialize() {
super.onInitialize();
container = new WebMarkupContainer("accessTokens");
container.setOutputMarkupId(true);
add(container);
container.add(new ListView<>("accessTokens", new AbstractReadOnlyModel<List<AccessToken>>() {
@Override
public List<AccessToken> getObject() {
return getUser().getAccessTokens();
}
}) {
private Component newViewer(String componentId, int index, AccessToken accessToken) {
return new AccessTokenPanel(componentId, accessToken) {
@Override
protected void onDelete(AjaxRequestTarget target) {
getUser().getAccessTokens().remove(index);
getUserManager().update(getUser(), null);
target.add(container);
}
@Override
protected void onEdit(AjaxRequestTarget target) {
AccessTokenEditPanel editor = new AccessTokenEditPanel("accessToken", accessToken) {
private void view(AjaxRequestTarget target) {
Component viewer = newViewer(componentId, index, accessToken);
replaceWith(viewer);
target.add(viewer);
}
@Override
protected void onSave(AjaxRequestTarget target, AccessToken accessToken) {
getUser().getAccessTokens().set(index, accessToken);
getUserManager().update(getUser(), null);
view(target);
}
@Override
protected void onCancel(AjaxRequestTarget target) {
view(target);
}
};
replaceWith(editor);
target.add(editor);
}
};
}
@Override
protected void populateItem(final ListItem<AccessToken> item) {
item.add(newViewer("accessToken", item.getIndex(), item.getModelObject()));
}
});
container.add(newAddNewFrag());
}
private Component newAddNewFrag() {
Fragment fragment = new Fragment("newAccessToken", "addNewLinkFrag", this);
fragment.add(new AjaxLink<Void>("link") {
@Override
public void onClick(AjaxRequestTarget target) {
Component editor = new AccessTokenEditPanel("newAccessToken", new AccessToken()) {
@Override
protected void onSave(AjaxRequestTarget target, AccessToken accessToken) {
getUser().getAccessTokens().add(accessToken);
getUserManager().update(getUser(), null);
container.replace(newAddNewFrag());
target.add(container);
}
@Override
protected void onCancel(AjaxRequestTarget target) {
Component newAddNewFrag = newAddNewFrag();
container.replace(newAddNewFrag);
target.add(newAddNewFrag);
}
};
container.replace(editor);
target.add(editor);
}
});
fragment.setOutputMarkupId(true);
return fragment;
}
private UserManager getUserManager() {
return OneDev.getInstance(UserManager.class);
}
}

View File

@ -1,11 +1,24 @@
<wicket:panel>
<div class="access-token">
<div class="input-group">
<input wicket:id="value" class="value form-control" readonly="readonly" type="password">
<span class="input-group-append">
<a wicket:id="copy" class="btn btn-outline-secondary btn-icon"><wicket:svg href="copy" class="icon"></wicket:svg></a>
</span>
<div class="access-token border rounded">
<div class="border-bottom px-4 py-3">
<span wicket:id="value" class="text-monospace font-weight-bolder mr-2"></span>
<a wicket:id="copy" class="btn btn-xs btn-icon btn-light btn-hover-primary mr-2" title="Copy to clipboard"><wicket:svg href="copy" class="icon"></wicket:svg></a>
<a wicket:id="edit" class="btn btn-xs btn-icon btn-light btn-hover-primary mr-2" title="Edit this rule"><wicket:svg href="edit" class="icon"></wicket:svg></a>
<a wicket:id="delete" class="btn btn-xs btn-icon btn-light btn-hover-danger mr-4" title="Delete this rule"><wicket:svg href="trash" class="icon"></wicket:svg></a>
</div>
<div class="px-4 pb-4">
<div class="mt-4">
<label class="font-weight-bolder">Description</label>
<div wicket:id="description"></div>
</div>
<div class="mt-4">
<label class="font-weight-bolder">Created At</label>
<div wicket:id="createdAt"></div>
</div>
<div class="mt-4">
<label class="font-weight-bolder">Expires At</label>
<div wicket:id="expiresAt"></div>
</div>
</div>
<a wicket:id="regenerate" class="btn btn-primary mt-4">Regenerate</a>
</div>
</wicket:panel>

View File

@ -1,61 +1,85 @@
package io.onedev.server.web.component.user.accesstoken;
import io.onedev.server.OneDev;
import io.onedev.server.entitymanager.UserManager;
import io.onedev.server.model.User;
import io.onedev.server.util.CryptoUtils;
import io.onedev.server.model.support.AccessToken;
import io.onedev.server.web.ajaxlistener.ConfirmClickListener;
import io.onedev.server.web.component.MultilineLabel;
import io.onedev.server.web.component.link.copytoclipboard.CopyToClipboardLink;
import io.onedev.server.web.util.ConfirmClickModifier;
import org.apache.wicket.Session;
import org.apache.wicket.markup.html.form.TextField;
import org.apache.wicket.markup.html.link.Link;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.ajax.attributes.AjaxRequestAttributes;
import org.apache.wicket.ajax.markup.html.AjaxLink;
import org.apache.wicket.behavior.AttributeAppender;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.model.AbstractReadOnlyModel;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.Model;
import java.util.Date;
import static io.onedev.server.util.DateUtils.formatDate;
@SuppressWarnings("serial")
public abstract class AccessTokenPanel extends Panel {
abstract class AccessTokenPanel extends Panel {
public AccessTokenPanel(String id) {
private final AccessToken accessToken;
public AccessTokenPanel(String id, AccessToken accessToken) {
super(id);
this.accessToken = accessToken;
}
protected abstract User getUser();
@Override
protected void onInitialize() {
super.onInitialize();
IModel<String> valueModel = new AbstractReadOnlyModel<String>() {
add(new Label("value", accessToken.getMaskedValue()));
add(new CopyToClipboardLink("copy", Model.of(accessToken.getValue())));
add(new AjaxLink<Void>("edit") {
@Override
public String getObject() {
return getUser().getAccessToken();
}
};
add(new TextField<String>("value", valueModel) {
@Override
protected String[] getInputTypes() {
return new String[] {"password"};
public void onClick(AjaxRequestTarget target) {
onEdit(target);
}
});
add(new CopyToClipboardLink("copy", valueModel));
add(new Link<Void>("regenerate") {
add(new AjaxLink<Void>("delete") {
@Override
public void onClick() {
getUser().setAccessToken(CryptoUtils.generateSecret());
OneDev.getInstance(UserManager.class).update(getUser(), null);
Session.get().success("Access token regenerated");
setResponsePage(getPage());
protected void updateAjaxAttributes(AjaxRequestAttributes attributes) {
super.updateAjaxAttributes(attributes);
attributes.getAjaxCallListeners().add(new ConfirmClickListener("Do you really want to delete this access token?"));
}
@Override
public void onClick(AjaxRequestTarget target) {
onDelete(target);
}
}.add(new ConfirmClickModifier("This will invalidate current token and generate a new one, do you want to continue?")));
});
if (accessToken.getDescription() != null)
add(new MultilineLabel("description", accessToken.getDescription()));
else
add(new Label("description", "<i>No description</i>").setEscapeModelStrings(false));
add(new Label("createdAt", formatDate(accessToken.getCreateDate())));
var expireDate = accessToken.getExpireDate();
if (expireDate != null) {
if (expireDate.before(new Date())) {
add(new Label("expiresAt", formatDate(expireDate))
.add(AttributeAppender.append("class", "text-danger")));
} else {
add(new Label("expiresAt", formatDate(expireDate)));
}
} else {
add(new Label("expiresAt", "Never expires"));
}
setOutputMarkupId(true);
}
protected abstract void onDelete(AjaxRequestTarget target);
protected abstract void onEdit(AjaxRequestTarget target);
}

View File

@ -49,7 +49,7 @@ import io.onedev.server.web.page.admin.usermanagement.InvitationListPage;
import io.onedev.server.web.page.admin.usermanagement.NewInvitationPage;
import io.onedev.server.web.page.admin.usermanagement.NewUserPage;
import io.onedev.server.web.page.admin.usermanagement.UserListPage;
import io.onedev.server.web.page.admin.usermanagement.accesstoken.UserAccessTokenPage;
import io.onedev.server.web.page.admin.usermanagement.accesstoken.UserAccessTokensPage;
import io.onedev.server.web.page.admin.usermanagement.authorization.UserAuthorizationsPage;
import io.onedev.server.web.page.admin.usermanagement.avatar.UserAvatarPage;
import io.onedev.server.web.page.admin.usermanagement.emailaddresses.UserEmailAddressesPage;
@ -65,7 +65,7 @@ import io.onedev.server.web.page.help.MethodDetailPage;
import io.onedev.server.web.page.help.ResourceDetailPage;
import io.onedev.server.web.page.help.ResourceListPage;
import io.onedev.server.web.page.issues.IssueListPage;
import io.onedev.server.web.page.my.accesstoken.MyAccessTokenPage;
import io.onedev.server.web.page.my.accesstoken.MyAccessTokensPage;
import io.onedev.server.web.page.my.avatar.MyAvatarPage;
import io.onedev.server.web.page.my.emailaddresses.MyEmailAddressesPage;
import io.onedev.server.web.page.my.gpgkeys.MyGpgKeysPage;
@ -174,7 +174,7 @@ public class BaseUrlMapper extends CompoundRequestMapper {
add(new BasePageMapper("~my/password", MyPasswordPage.class));
add(new BasePageMapper("~my/ssh-keys", MySshKeysPage.class));
add(new BasePageMapper("~my/gpg-keys", MyGpgKeysPage.class));
add(new BasePageMapper("~my/access-token", MyAccessTokenPage.class));
add(new BasePageMapper("~my/access-tokens", MyAccessTokensPage.class));
add(new BasePageMapper("~my/two-factor-authentication", MyTwoFactorAuthenticationPage.class));
}
@ -233,7 +233,7 @@ public class BaseUrlMapper extends CompoundRequestMapper {
add(new BasePageMapper("~administration/users/${user}/password", UserPasswordPage.class));
add(new BasePageMapper("~administration/users/${user}/ssh-keys", UserSshKeysPage.class));
add(new BasePageMapper("~administration/users/${user}/gpg-keys", UserGpgKeysPage.class));
add(new BasePageMapper("~administration/users/${user}/access-token", UserAccessTokenPage.class));
add(new BasePageMapper("~administration/users/${user}/access-tokens", UserAccessTokensPage.class));
add(new BasePageMapper("~administration/users/${user}/two-factor-authentication",
UserTwoFactorAuthenticationPage.class));
add(new BasePageMapper("~administration/invitations", InvitationListPage.class));

View File

@ -23,7 +23,7 @@ import io.onedev.server.model.User;
import io.onedev.server.web.component.tabbable.PageTab;
import io.onedev.server.web.component.tabbable.Tabbable;
import io.onedev.server.web.page.admin.AdministrationPage;
import io.onedev.server.web.page.admin.usermanagement.accesstoken.UserAccessTokenPage;
import io.onedev.server.web.page.admin.usermanagement.accesstoken.UserAccessTokensPage;
import io.onedev.server.web.page.admin.usermanagement.authorization.UserAuthorizationsPage;
import io.onedev.server.web.page.admin.usermanagement.avatar.UserAvatarPage;
import io.onedev.server.web.page.admin.usermanagement.emailaddresses.UserEmailAddressesPage;
@ -76,7 +76,7 @@ public abstract class UserPage extends AdministrationPage {
tabs.add(new UserTab("Authorized Projects", "project", UserAuthorizationsPage.class));
tabs.add(new UserTab("SSH Keys", "key", UserSshKeysPage.class));
tabs.add(new UserTab("GPG Keys", "key", UserGpgKeysPage.class));
tabs.add(new UserTab("Access Token", "token", UserAccessTokenPage.class));
tabs.add(new UserTab("Access Tokens", "token", UserAccessTokensPage.class));
tabs.add(new UserTab("Two-factor Authentication", "shield", UserTwoFactorAuthenticationPage.class));
add(new Tabbable("userTabs", tabs));

View File

@ -1,8 +0,0 @@
<wicket:extend>
<div id="user-access-token">
<div class="font-size-sm text-muted mb-4"><wicket:svg href="bulb" class="icon"/> Access token
can be used to clone code via HTTP(S) protocol without using password. It has same permission
over all projects accessible by this user</div>
<div wicket:id="accessToken"></div>
</div>
</wicket:extend>

View File

@ -0,0 +1,9 @@
<wicket:extend>
<div id="user-access-tokens">
<div class="alert alert-notice alert-light mb-5">
Access token can be used to clone code via HTTP(S) protocol without using password.
It has same permission over all projects accessible by this user
</div>
<div wicket:id="accessTokens"></div>
</div>
</wicket:extend>

View File

@ -3,13 +3,13 @@ package io.onedev.server.web.page.admin.usermanagement.accesstoken;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import io.onedev.server.model.User;
import io.onedev.server.web.component.user.accesstoken.AccessTokenPanel;
import io.onedev.server.web.component.user.accesstoken.AccessTokenListPanel;
import io.onedev.server.web.page.admin.usermanagement.UserPage;
@SuppressWarnings("serial")
public class UserAccessTokenPage extends UserPage {
public class UserAccessTokensPage extends UserPage {
public UserAccessTokenPage(PageParameters params) {
public UserAccessTokensPage(PageParameters params) {
super(params);
}
@ -17,11 +17,11 @@ public class UserAccessTokenPage extends UserPage {
protected void onInitialize() {
super.onInitialize();
add(new AccessTokenPanel("accessToken") {
add(new AccessTokenListPanel("accessTokens") {
@Override
protected User getUser() {
return UserAccessTokenPage.this.getUser();
return UserAccessTokensPage.this.getUser();
}
});

View File

@ -87,9 +87,9 @@
<wicket:svg href="key" class="icon mr-2"></wicket:svg>
GPG Keys
</a>
<a wicket:id="myAccessToken" class="dropdown-item">
<a wicket:id="myAccessTokens" class="dropdown-item">
<wicket:svg href="token" class="icon mr-2"></wicket:svg>
Access Token
Access Tokens
</a>
<a wicket:id="myTwoFactorAuthentication" class="dropdown-item">
<wicket:svg href="shield" class="icon mr-2"></wicket:svg>

View File

@ -59,7 +59,7 @@ import io.onedev.server.web.page.admin.usermanagement.*;
import io.onedev.server.web.page.base.BasePage;
import io.onedev.server.web.page.help.IncompatibilitiesPage;
import io.onedev.server.web.page.my.MyPage;
import io.onedev.server.web.page.my.accesstoken.MyAccessTokenPage;
import io.onedev.server.web.page.my.accesstoken.MyAccessTokensPage;
import io.onedev.server.web.page.my.avatar.MyAvatarPage;
import io.onedev.server.web.page.my.emailaddresses.MyEmailAddressesPage;
import io.onedev.server.web.page.my.gpgkeys.MyGpgKeysPage;
@ -70,7 +70,6 @@ import io.onedev.server.web.page.my.twofactorauthentication.MyTwoFactorAuthentic
import io.onedev.server.web.page.simple.security.LoginPage;
import io.onedev.server.web.page.simple.security.LogoutPage;
import io.onedev.server.web.util.WicketUtils;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.wicket.Component;
import org.apache.wicket.RestartResponseAtInterceptPageException;
import org.apache.wicket.RestartResponseException;
@ -507,8 +506,8 @@ public abstract class LayoutPage extends BasePage {
if (getPage() instanceof MyGpgKeysPage)
item.add(AttributeAppender.append("class", "active"));
userInfo.add(item = new ViewStateAwarePageLink<Void>("myAccessToken", MyAccessTokenPage.class));
if (getPage() instanceof MyAccessTokenPage)
userInfo.add(item = new ViewStateAwarePageLink<Void>("myAccessTokens", MyAccessTokensPage.class));
if (getPage() instanceof MyAccessTokensPage)
item.add(AttributeAppender.append("class", "active"));
userInfo.add(item = new ViewStateAwarePageLink<Void>("myTwoFactorAuthentication", MyTwoFactorAuthenticationPage.class));

View File

@ -1,11 +1,11 @@
<wicket:extend>
<div class="card m-2 m-sm-5">
<div class="card-body">
<div class="font-size-sm text-muted mb-4">
<wicket:svg href="bulb" class="icon"/> Access token can be used to clone code via HTTP(S) protocol without
<div class="alert alert-notice alert-light mb-5">
Access token can be used to clone code via HTTP(S) protocol without
using password. It has same permission over all projects accessible by your account.
</div>
<div wicket:id="accessToken"></div>
<div wicket:id="accessTokens"></div>
</div>
</div>
</wicket:extend>

View File

@ -5,13 +5,13 @@ import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import io.onedev.server.model.User;
import io.onedev.server.web.component.user.accesstoken.AccessTokenPanel;
import io.onedev.server.web.component.user.accesstoken.AccessTokenListPanel;
import io.onedev.server.web.page.my.MyPage;
@SuppressWarnings("serial")
public class MyAccessTokenPage extends MyPage {
public class MyAccessTokensPage extends MyPage {
public MyAccessTokenPage(PageParameters params) {
public MyAccessTokensPage(PageParameters params) {
super(params);
}
@ -19,7 +19,7 @@ public class MyAccessTokenPage extends MyPage {
protected void onInitialize() {
super.onInitialize();
add(new AccessTokenPanel("accessToken") {
add(new AccessTokenListPanel("accessTokens") {
@Override
protected User getUser() {
@ -32,7 +32,7 @@ public class MyAccessTokenPage extends MyPage {
@Override
protected Component newTopbarTitle(String componentId) {
return new Label(componentId, "My Access Token");
return new Label(componentId, "My Access Tokens");
}
}

View File

@ -51,14 +51,14 @@ public class BranchProtectionsPage extends ProjectSettingPage {
@Override
protected void onDelete(AjaxRequestTarget target) {
getProject().getBranchProtections().remove(item.getIndex());
OneDev.getInstance(ProjectManager.class).update(getProject());
getProjectManager().update(getProject());
target.add(container);
}
@Override
protected void onSave(AjaxRequestTarget target, BranchProtection protection) {
getProject().getBranchProtections().set(item.getIndex(), protection);
OneDev.getInstance(ProjectManager.class).update(getProject());
getProjectManager().update(getProject());
}
});
@ -73,7 +73,7 @@ public class BranchProtectionsPage extends ProjectSettingPage {
List<BranchProtection> protections = getProject().getBranchProtections();
BranchProtection protection = protections.get(from.getItemIndex());
protections.set(from.getItemIndex(), protections.set(to.getItemIndex(), protection));
OneDev.getInstance(ProjectManager.class).update(getProject());
getProjectManager().update(getProject());
target.add(container);
}
@ -96,7 +96,7 @@ public class BranchProtectionsPage extends ProjectSettingPage {
@Override
protected void onSave(AjaxRequestTarget target, BranchProtection protection) {
getProject().getBranchProtections().add(protection);
OneDev.getInstance(ProjectManager.class).update(getProject());
getProjectManager().update(getProject());
container.replace(newAddNewFrag());
target.add(container);
}
@ -117,7 +117,7 @@ public class BranchProtectionsPage extends ProjectSettingPage {
fragment.setOutputMarkupId(true);
return fragment;
}
@Override
protected Component newProjectTitle(String componentId) {
return new Label(componentId, "Branch Protection");