diff --git a/app/adapters/api-token.js b/app/adapters/api-token.js
new file mode 100644
index 00000000000..2cccb38a90a
--- /dev/null
+++ b/app/adapters/api-token.js
@@ -0,0 +1,8 @@
+import DS from 'ember-data';
+
+export default DS.RESTAdapter.extend({
+ namespace: 'me',
+ pathForType() {
+ return 'tokens';
+ }
+});
diff --git a/app/components/api-token-row.js b/app/components/api-token-row.js
new file mode 100644
index 00000000000..2aaea4052ae
--- /dev/null
+++ b/app/components/api-token-row.js
@@ -0,0 +1,43 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+ emptyName: Ember.computed.empty('api_token.name'),
+ disableCreate: Ember.computed.or('api_token.isSaving', 'emptyName'),
+ serverError: null,
+
+ didInsertElement() {
+ if (this.get('api_token.isNew')) {
+ this.$('input').focus();
+ }
+ },
+
+ actions: {
+ saveToken() {
+ this.get('api_token')
+ .save()
+ .then(() => this.set('serverError', null))
+ .catch(err => {
+ let msg;
+ if (err.errors && err.errors[0] && err.errors[0].detail) {
+ msg = `An error occurred while saving this token, ${err.errors[0].detail}`;
+ } else {
+ msg = 'An unknown error occurred while saving this token';
+ }
+ this.set('serverError', msg);
+ });
+ },
+ revokeToken() {
+ this.get('api_token')
+ .destroyRecord()
+ .catch(err => {
+ let msg;
+ if (err.errors && err.errors[0] && err.errors[0].detail) {
+ msg = `An error occurred while revoking this token, ${err.errors[0].detail}`;
+ } else {
+ msg = 'An unknown error occurred while revoking this token';
+ }
+ this.set('serverError', msg);
+ });
+ },
+ }
+});
diff --git a/app/controllers/me/index.js b/app/controllers/me/index.js
index 70022cc0855..36ea46a76f0 100644
--- a/app/controllers/me/index.js
+++ b/app/controllers/me/index.js
@@ -3,6 +3,9 @@ import Ember from 'ember';
const { inject: { service } } = Ember;
export default Ember.Controller.extend({
+ tokenSort: ['created_at:desc'],
+
+ sortedTokens: Ember.computed.sort('model.api_tokens', 'tokenSort'),
ajax: service(),
@@ -10,26 +13,14 @@ export default Ember.Controller.extend({
isResetting: false,
+ newTokens: Ember.computed.filterBy('model.api_tokens', 'isNew', true),
+ disableCreate: Ember.computed.notEmpty('newTokens'),
+
actions: {
- resetToken() {
- this.set('isResetting', true);
-
- this.get('ajax').put('/me/reset_token').then((d) => {
- this.get('model').set('api_token', d.api_token);
- }).catch((reason) => {
- let msg;
- if (reason.status === 403) {
- msg = 'A login is required to perform this action';
- } else {
- msg = 'An unknown error occurred';
- }
- this.get('flashMessages').queue(msg);
- // TODO: this should be an action, the route state machine
- // should receive signals not external transitions
- this.transitionToRoute('index');
- }).finally(() => {
- this.set('isResetting', false);
+ startNewToken() {
+ this.get('store').createRecord('api-token', {
+ created_at: new Date(Date.now() + 2000),
});
- }
+ },
}
});
diff --git a/app/models/api-token.js b/app/models/api-token.js
new file mode 100644
index 00000000000..2d9e01ca6d6
--- /dev/null
+++ b/app/models/api-token.js
@@ -0,0 +1,8 @@
+import DS from 'ember-data';
+
+export default DS.Model.extend({
+ name: DS.attr('string'),
+ token: DS.attr('string'),
+ created_at: DS.attr('date'),
+ last_used_at: DS.attr('date'),
+});
diff --git a/app/models/user.js b/app/models/user.js
index 3ea87236bc7..36cca400565 100644
--- a/app/models/user.js
+++ b/app/models/user.js
@@ -4,7 +4,6 @@ export default DS.Model.extend({
email: DS.attr('string'),
name: DS.attr('string'),
login: DS.attr('string'),
- api_token: DS.attr('string'),
avatar: DS.attr('string'),
url: DS.attr('string'),
kind: DS.attr('string'),
diff --git a/app/routes/application.js b/app/routes/application.js
index fc8b380bfd8..1b27eb5e29f 100644
--- a/app/routes/application.js
+++ b/app/routes/application.js
@@ -12,9 +12,7 @@ export default Ember.Route.extend({
if (this.session.get('isLoggedIn') &&
this.session.get('currentUser') === null) {
this.get('ajax').request('/me').then((response) => {
- let user = this.store.push(this.store.normalize('user', response.user));
- user.set('api_token', response.api_token);
- this.session.set('currentUser', user);
+ this.session.set('currentUser', this.store.push(this.store.normalize('user', response.user)));
}).catch(() => this.session.logoutUser()).finally(() => {
window.currentUserDetected = true;
Ember.$(window).trigger('currentUserDetected');
diff --git a/app/routes/login.js b/app/routes/login.js
index 0917843a67d..5f479245684 100644
--- a/app/routes/login.js
+++ b/app/routes/login.js
@@ -67,7 +67,6 @@ export default Ember.Route.extend({
}
let user = this.store.push(this.store.normalize('user', data.user));
- user.set('api_token', data.api_token);
let transition = this.session.get('savedTransition');
this.session.loginUser(user);
if (transition) {
diff --git a/app/routes/me/index.js b/app/routes/me/index.js
index 6186e7d6e8d..afee5ad96ab 100644
--- a/app/routes/me/index.js
+++ b/app/routes/me/index.js
@@ -3,6 +3,9 @@ import AuthenticatedRoute from '../../mixins/authenticated-route';
export default Ember.Route.extend(AuthenticatedRoute, {
model() {
- return this.session.get('currentUser');
+ return {
+ user: this.session.get('currentUser'),
+ api_tokens: this.get('store').findAll('api-token'),
+ };
},
});
diff --git a/app/serializers/api-token.js b/app/serializers/api-token.js
new file mode 100644
index 00000000000..090579c4503
--- /dev/null
+++ b/app/serializers/api-token.js
@@ -0,0 +1,7 @@
+import DS from 'ember-data';
+
+export default DS.RESTSerializer.extend({
+ payloadKeyFromModelName() {
+ return 'api_token';
+ }
+});
diff --git a/app/styles/home.scss b/app/styles/home.scss
index ef60beb1bf0..fe469aad5d7 100644
--- a/app/styles/home.scss
+++ b/app/styles/home.scss
@@ -1,6 +1,9 @@
@mixin button($start, $end) {
- $s2: darken($start, 5%);
- $e2: darken($end, 5%);
+ $start_dark: darken($start, 5%);
+ $end_dark: darken($end, 5%);
+ $start_light: lighten($start, 5%);
+ $end_light: lighten($end, 5%);
+
padding: 15px 40px;
display: inline-block;
color: $main-color;
@@ -17,8 +20,12 @@
margin-right: 10px;
}
- &:hover { @include vertical-gradient($s2, $e2); outline: 0; }
- &.active { @include vertical-gradient($s2, $e2); outline: 0; }
+ &:hover { @include vertical-gradient($start_dark, $end_dark); outline: 0; }
+ &.active { @include vertical-gradient($start_dark, $end_dark); outline: 0; }
+ &[disabled] {
+ @include vertical-gradient($start_light, $end_light);
+ color: $main-color-light;
+ }
}
.yellow-button {
@@ -26,6 +33,11 @@
vertical-align: middle;
}
+button.small {
+ padding: 10px 20px;
+ @include border-radius(30px);
+}
+
.tan-button {
@include button(rgb(232, 227, 199), rgb(214, 205, 153));
}
diff --git a/app/styles/me.scss b/app/styles/me.scss
index 289ecb7c6de..377b3483da6 100644
--- a/app/styles/me.scss
+++ b/app/styles/me.scss
@@ -86,3 +86,66 @@
&:hover { background-color: darken($bg, 10%); }
}
}
+
+.me-subheading {
+ @include display-flex;
+ .right {
+ @include flex(2);
+ @include display-flex;
+ @include justify-content(flex-end);
+ @include align-self(center);
+ }
+}
+
+#tokens {
+ background-color: $main-bg-dark;
+ @include display-flex;
+ @include flex-direction(column);
+ .row {
+ width: 100%;
+ border: 1px solid #d5d3cb;
+ border-bottom-width: 0px;
+ &:last-child { border-bottom-width: 1px; }
+ padding: 10px 20px;
+ @include display-flex;
+ @include align-items(center);
+ .name {
+ @include flex(1);
+ margin-right: 0.4em;
+ font-weight: bold;
+ }
+ .dates {
+ @include flex(content);
+ @include display-flex;
+ @include flex-direction(column);
+ @include align-items(flex-end);
+ margin-right: 0.4em;
+ }
+ .actions {
+ @include display-flex;
+ @include align-items(center);
+ img { margin-left: 10px }
+ }
+ }
+ .create-token {
+ .name {
+ input {
+ width: 100%;
+ }
+ padding-right: 20px;
+ margin-right: 0px;
+ }
+ background-color: $main-bg-dark;
+ }
+ .new-token {
+ border-top-width: 0px;
+ @include flex-direction(column);
+ @include justify-content(stretch);
+ }
+ .error {
+ border-top-width: 0px;
+ font-weight: bold;
+ color: rgb(216, 0, 41);
+ padding: 0px 10px 10px 20px;
+ }
+}
diff --git a/app/templates/components/api-token-row.hbs b/app/templates/components/api-token-row.hbs
new file mode 100644
index 00000000000..346e5f447aa
--- /dev/null
+++ b/app/templates/components/api-token-row.hbs
@@ -0,0 +1,74 @@
+
+
+ {{#if api_token.isNew}}
+ {{input
+ type="text"
+ placeholder="New token name"
+ disabled=api_token.isSaving
+ value=api_token.name
+ autofocus=true
+ enter="saveToken"}}
+ {{else}}
+ {{ api_token.name }}
+ {{/if}}
+
+
+
+
+ {{#unless api_token.isNew}}
+
+
+
+ Created {{moment-from-now api_token.created_at}}
+
+
+ {{#if api_token.last_used_at}}
+
+
+ Last used {{moment-from-now api_token.last_used_at}}
+
+
+ {{else}}
+
+ Never used
+
+ {{/if}}
+
+ {{/unless}}
+
+
+ {{#if api_token.isNew}}
+
Create
+ {{else}}
+
Revoke
+ {{/if}}
+ {{#if api_token.isSaving}}
+
+ {{/if}}
+
+
+
+{{#if serverError}}
+
+
+ {{ serverError }}
+
+
+{{/if}}
+
+{{#if api_token.token}}
+
+
+ Please record this token somewhere, you cannot retrieve
+ its value again. For use on the command line you can save it to
~/.cargo/config
+ with:
+
+
cargo login {{ api_token.token }}
+
+
+{{/if}}
diff --git a/app/templates/me/index.hbs b/app/templates/me/index.hbs
index 14f0c177f40..d5766f611e0 100644
--- a/app/templates/me/index.hbs
+++ b/app/templates/me/index.hbs
@@ -9,33 +9,36 @@
Profile Information
- {{#user-link user=model }} {{user-avatar user=model size='medium'}} {{/user-link}}
+ {{#user-link user=model.user }} {{user-avatar user=model.user size='medium'}} {{/user-link}}
Name
- {{ model.name }}
+ {{ model.user.name }}
GitHub Account
- {{ model.login }}
+ {{ model.user.login }}
Email
- {{ model.email }}
+ {{ model.user.email }}
-
API Access
+
+
API Access
+
+ New Token
+
+
-
Your API key is {{ model.api_token }}
If you want to use package commands from the command line, you'll need a
- ~/.cargo/config
which can be generated with:
+ ~/.cargo/config
, the first step to creating this
+ is to generate a new token using the button above.
-
cargo login {{ model.api_token }}
-
-
- Reset my API key
-
-
+
+ {{#each sortedTokens as |api_token|}}
+ {{api-token-row api_token=api_token}}
+ {{/each}}
+
diff --git a/migrations/20170428154714_multiple_api_tokens/down.sql b/migrations/20170428154714_multiple_api_tokens/down.sql
new file mode 100644
index 00000000000..1d115ccb8a5
--- /dev/null
+++ b/migrations/20170428154714_multiple_api_tokens/down.sql
@@ -0,0 +1 @@
+DROP TABLE api_tokens;
diff --git a/migrations/20170428154714_multiple_api_tokens/up.sql b/migrations/20170428154714_multiple_api_tokens/up.sql
new file mode 100644
index 00000000000..681ae3814eb
--- /dev/null
+++ b/migrations/20170428154714_multiple_api_tokens/up.sql
@@ -0,0 +1,16 @@
+CREATE TABLE api_tokens (
+ id SERIAL PRIMARY KEY,
+ user_id integer NOT NULL REFERENCES users(id),
+ token character varying DEFAULT random_string(32) NOT NULL UNIQUE,
+ name character varying NOT NULL,
+ created_at timestamp without time zone DEFAULT now() NOT NULL,
+ last_used_at timestamp without time zone
+);
+
+CREATE INDEX ON api_tokens (token);
+
+INSERT INTO api_tokens (user_id, token, name)
+ SELECT id, api_token, 'Initial token' FROM users;
+
+-- To be done in a cleanup migration later.
+-- ALTER TABLE users DROP COLUMN api_token;
diff --git a/src/lib.rs b/src/lib.rs
index 55388b830cc..feb2331256d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -85,6 +85,7 @@ pub mod krate;
pub mod model;
pub mod owner;
pub mod schema;
+pub mod token;
pub mod upload;
pub mod uploaders;
pub mod user;
@@ -164,8 +165,10 @@ pub fn middleware(app: Arc) -> MiddlewareBuilder {
router.get("/authorize", C(user::github_access_token));
router.get("/logout", C(user::logout));
router.get("/me", C(user::me));
- router.put("/me/reset_token", C(user::reset_token));
router.get("/me/updates", C(user::updates));
+ router.get("/me/tokens", C(token::list));
+ router.post("/me/tokens", C(token::new));
+ router.delete("/me/tokens/:id", C(token::revoke));
router.get("/summary", C(krate::summary));
let env = app.config.env;
diff --git a/src/schema.rs b/src/schema.rs
index 9f973a040ba..fafb0c0dedb 100644
--- a/src/schema.rs
+++ b/src/schema.rs
@@ -1,5 +1,16 @@
// This file can be regenerated with `diesel print-schema`
+table! {
+ api_tokens (id) {
+ id -> Int4,
+ user_id -> Int4,
+ token -> Varchar,
+ name -> Varchar,
+ created_at -> Timestamp,
+ last_used_at -> Nullable,
+ }
+}
+
table! {
badges (crate_id,
badge_type) {
@@ -132,7 +143,6 @@ table! {
id -> Int4,
email -> Nullable,
gh_access_token -> Varchar,
- api_token -> Varchar,
gh_login -> Varchar,
name -> Nullable,
gh_avatar -> Nullable,
diff --git a/src/tests/all.rs b/src/tests/all.rs
index a1e13f16774..b93af52ad38 100644
--- a/src/tests/all.rs
+++ b/src/tests/all.rs
@@ -34,6 +34,7 @@ use cargo_registry::upload as u;
use cargo_registry::user::NewUser;
use cargo_registry::owner::{CrateOwner, NewTeam, Team};
use cargo_registry::version::NewVersion;
+use cargo_registry::user::AuthenticationSource;
use cargo_registry::{User, Crate, Version, Dependency, Category, Model, Replica};
use conduit::{Request, Method};
use conduit_test::MockRequest;
@@ -87,6 +88,7 @@ mod keyword;
mod krate;
mod record;
mod team;
+mod token;
mod user;
mod version;
@@ -212,7 +214,6 @@ fn user(login: &str) -> User {
name: None,
gh_avatar: None,
gh_access_token: "some random token".into(),
- api_token: "some random token".into(),
}
}
@@ -455,6 +456,9 @@ fn mock_user(req: &mut Request, u: User) -> User {
fn sign_in_as(req: &mut Request, user: &User) {
req.mut_extensions().insert(user.clone());
+ req.mut_extensions().insert(
+ AuthenticationSource::SessionCookie,
+ );
}
fn sign_in(req: &mut Request, app: &App) {
diff --git a/src/tests/http-data/krate_new_krate_with_token b/src/tests/http-data/krate_new_krate_with_token
new file mode 100644
index 00000000000..204372eb899
--- /dev/null
+++ b/src/tests/http-data/krate_new_krate_with_token
@@ -0,0 +1,21 @@
+===REQUEST 339
+PUT http://alexcrichton-test.s3.amazonaws.com/crates/foo_new/foo_new-1.0.0.crate HTTP/1.1
+Accept: */*
+Proxy-Connection: Keep-Alive
+Authorization: AWS AKIAJF3GEK7N44BACDZA:GDxGb6r3SIqo9wXuzHrgMNWekwk=
+Content-Length: 0
+Host: alexcrichton-test.s3.amazonaws.com
+Content-Type: application/x-tar
+Date: Sun, 28 Jun 2015 14:07:17 -0700
+
+
+===RESPONSE 258
+HTTP/1.1 200
+x-amz-request-id: CB0E925D8E3AB3E8
+x-amz-id-2: SiaMwszM1p2TzXlLauvZ6kRKcUCg7HoyBW29vts42w9ArrLwkJWl8vuvPuGFkpM6XGH+YXN852g=
+date: Sun, 28 Jun 2015 21:07:51 GMT
+etag: "d41d8cd98f00b204e9800998ecf8427e"
+content-length: 0
+server: AmazonS3
+
+
diff --git a/src/tests/krate.rs b/src/tests/krate.rs
index ba003d53160..feff51d9c9b 100644
--- a/src/tests/krate.rs
+++ b/src/tests/krate.rs
@@ -17,6 +17,7 @@ use cargo_registry::download::EncodableVersionDownload;
use cargo_registry::git;
use cargo_registry::keyword::EncodableKeyword;
use cargo_registry::krate::{Crate, EncodableCrate, MAX_NAME_LENGTH};
+use cargo_registry::token::ApiToken;
use cargo_registry::owner::EncodableOwner;
use cargo_registry::schema::versions;
use cargo_registry::upload as u;
@@ -478,6 +479,24 @@ fn new_krate() {
assert_eq!(json.krate.max_version, "1.0.0");
}
+#[test]
+fn new_krate_with_token() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::new_req(app.clone(), "foo_new", "1.0.0");
+
+ {
+ let conn = t!(app.diesel_database.get());
+ let user = t!(::new_user("foo").create_or_update(&conn));
+ let token = t!(ApiToken::insert(&conn, user.id, "bar"));
+ req.header("Authorization", &token.token);
+ }
+
+ let mut response = ok_resp!(middle.call(&mut req));
+ let json: GoodCrate = ::json(&mut response);
+ assert_eq!(json.krate.name, "foo_new");
+ assert_eq!(json.krate.max_version, "1.0.0");
+}
+
#[test]
fn new_krate_with_reserved_name() {
fn test_bad_name(name: &str) {
diff --git a/src/tests/token.rs b/src/tests/token.rs
new file mode 100644
index 00000000000..e3323e0b1cb
--- /dev/null
+++ b/src/tests/token.rs
@@ -0,0 +1,441 @@
+use std::collections::HashSet;
+
+use conduit::{Handler, Method};
+
+use cargo_registry::token::{ApiToken, EncodableApiToken, EncodableApiTokenWithToken};
+
+#[derive(RustcDecodable)]
+struct ListResponse {
+ api_tokens: Vec,
+}
+#[derive(RustcDecodable)]
+struct NewResponse {
+ api_token: EncodableApiTokenWithToken,
+}
+#[derive(RustcDecodable)]
+struct RevokedResponse {}
+
+macro_rules! assert_contains {
+ ($e:expr, $f:expr) => {
+ if !$e.contains($f) {
+ panic!(format!("expected '{}' to contain '{}'", $e, $f));
+ }
+ }
+}
+
+#[test]
+fn list_logged_out() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Get, "/me/tokens");
+
+ let response = t_resp!(middle.call(&mut req));
+ assert_eq!(response.status.0, 403);
+}
+
+#[test]
+fn list_empty() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Get, "/me/tokens");
+
+ let user = {
+ let conn = t!(app.diesel_database.get());
+ t!(::new_user("foo").create_or_update(&conn))
+ };
+ ::sign_in_as(&mut req, &user);
+
+ let mut response = ok_resp!(middle.call(&mut req));
+ let json: ListResponse = ::json(&mut response);
+
+ assert_eq!(json.api_tokens.len(), 0);
+}
+
+#[test]
+fn list_tokens() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Get, "/me/tokens");
+
+ let (user, tokens);
+ {
+ let conn = t!(app.diesel_database.get());
+ user = t!(::new_user("foo").create_or_update(&conn));
+ tokens = vec![
+ t!(ApiToken::insert(&conn, user.id, "bar")),
+ t!(ApiToken::insert(&conn, user.id, "baz")),
+ ];
+ }
+ ::sign_in_as(&mut req, &user);
+
+ let mut response = ok_resp!(middle.call(&mut req));
+ let json: ListResponse = ::json(&mut response);
+
+ assert_eq!(json.api_tokens.len(), tokens.len());
+ assert_eq!(
+ json.api_tokens
+ .into_iter()
+ .map(|t| t.name)
+ .collect::>(),
+ tokens.into_iter().map(|t| t.name).collect::>()
+ );
+}
+
+#[test]
+fn create_token_logged_out() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Post, "/me/tokens");
+
+ req.with_body(br#"{ "api_token": { "name": "bar" } }"#);
+
+ let response = t_resp!(middle.call(&mut req));
+ assert_eq!(response.status.0, 403);
+}
+
+#[test]
+fn create_token_invalid_request() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Post, "/me/tokens");
+
+ let user = {
+ let conn = t!(app.diesel_database.get());
+ t!(::new_user("foo").create_or_update(&conn))
+ };
+ ::sign_in_as(&mut req, &user);
+ req.with_body(br#"{ "name": "" }"#);
+
+ let mut response = t_resp!(middle.call(&mut req));
+ let json: ::Bad = ::json(&mut response);
+
+ assert_eq!(response.status.0, 400);
+ assert_contains!(json.errors[0].detail, "invalid new token request");
+}
+
+#[test]
+fn create_token_no_name() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Post, "/me/tokens");
+
+ let user = {
+ let conn = t!(app.diesel_database.get());
+ t!(::new_user("foo").create_or_update(&conn))
+ };
+ ::sign_in_as(&mut req, &user);
+ req.with_body(br#"{ "api_token": { "name": "" } }"#);
+
+ let mut response = t_resp!(middle.call(&mut req));
+ let json: ::Bad = ::json(&mut response);
+
+ assert_eq!(response.status.0, 400);
+ assert_eq!(json.errors[0].detail, "name must have a value");
+}
+
+#[test]
+fn create_token_long_body() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Post, "/me/tokens");
+
+ let user = {
+ let conn = t!(app.diesel_database.get());
+ t!(::new_user("foo").create_or_update(&conn))
+ };
+ ::sign_in_as(&mut req, &user);
+ req.with_body(&[5; 5192]); // Send a request with a 5kB body of 5's
+
+ let mut response = t_resp!(middle.call(&mut req));
+ let json: ::Bad = ::json(&mut response);
+
+ assert_eq!(response.status.0, 400);
+ assert_contains!(json.errors[0].detail, "max post size");
+}
+
+#[test]
+fn create_token_exceeded_tokens_per_user() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Post, "/me/tokens");
+
+ let user;
+ {
+ let conn = t!(app.diesel_database.get());
+ user = t!(::new_user("foo").create_or_update(&conn));
+ for i in 0..1000 {
+ t!(ApiToken::insert(&conn, user.id, &format!("token {}", i)));
+ }
+ };
+ ::sign_in_as(&mut req, &user);
+ req.with_body(br#"{ "api_token": { "name": "bar" } }"#);
+
+ let mut response = t_resp!(middle.call(&mut req));
+ let json: ::Bad = ::json(&mut response);
+
+ assert_eq!(response.status.0, 400);
+ assert_contains!(json.errors[0].detail, "maximum tokens per user");
+}
+
+#[test]
+fn create_token_success() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Post, "/me/tokens");
+
+ let user = {
+ let conn = t!(app.diesel_database.get());
+ t!(::new_user("foo").create_or_update(&conn))
+ };
+ ::sign_in_as(&mut req, &user);
+ req.with_body(br#"{ "api_token": { "name": "bar" } }"#);
+
+ let mut response = ok_resp!(middle.call(&mut req));
+ let json: NewResponse = ::json(&mut response);
+
+ assert_eq!(json.api_token.name, "bar");
+ assert!(!json.api_token.token.is_empty());
+
+ let conn = t!(app.diesel_database.get());
+ let tokens = t!(ApiToken::find_for_user(&conn, user.id));
+ assert_eq!(tokens.len(), 1);
+ assert_eq!(tokens[0].name, "bar");
+ assert_eq!(tokens[0].token, json.api_token.token);
+ assert_eq!(tokens[0].last_used_at, None);
+}
+
+#[test]
+fn create_token_multiple_have_different_values() {
+ let (_b, app, middle) = ::app();
+
+ let user = {
+ let conn = t!(app.clone().diesel_database.get());
+ t!(::new_user("foo").create_or_update(&conn))
+ };
+
+ let first = {
+ let mut req = ::req(app.clone(), Method::Post, "/me/tokens");
+ ::sign_in_as(&mut req, &user);
+ req.with_body(br#"{ "api_token": { "name": "bar" } }"#);
+ ::json::(&mut ok_resp!(middle.call(&mut req)))
+ };
+
+ let second = {
+ let mut req = ::req(app.clone(), Method::Post, "/me/tokens");
+ ::sign_in_as(&mut req, &user);
+ req.with_body(br#"{ "api_token": { "name": "bar" } }"#);
+ ::json::(&mut ok_resp!(middle.call(&mut req)))
+ };
+
+ assert_ne!(first.api_token.token, second.api_token.token);
+}
+
+#[test]
+fn create_token_multiple_users_have_different_values() {
+ let (_b, app, middle) = ::app();
+
+ let first_user = {
+ let conn = t!(app.clone().diesel_database.get());
+ t!(::new_user("foo").create_or_update(&conn))
+ };
+
+ let second_user = {
+ let conn = t!(app.clone().diesel_database.get());
+ t!(::new_user("bar").create_or_update(&conn))
+ };
+
+ let first_token = {
+ let mut req = ::req(app.clone(), Method::Post, "/me/tokens");
+ ::sign_in_as(&mut req, &first_user);
+ req.with_body(br#"{ "api_token": { "name": "baz" } }"#);
+ ::json::(&mut ok_resp!(middle.call(&mut req)))
+ };
+
+ let second_token = {
+ let mut req = ::req(app.clone(), Method::Post, "/me/tokens");
+ ::sign_in_as(&mut req, &second_user);
+ req.with_body(br#"{ "api_token": { "name": "baz" } }"#);
+ ::json::(&mut ok_resp!(middle.call(&mut req)))
+ };
+
+ assert_ne!(first_token.api_token.token, second_token.api_token.token);
+}
+
+#[test]
+fn create_token_with_token() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Post, "/me/tokens");
+
+ let (user, token);
+ {
+ let conn = t!(app.diesel_database.get());
+ user = t!(::new_user("foo").create_or_update(&conn));
+ token = t!(ApiToken::insert(&conn, user.id, "bar"));
+ }
+ req.header("Authorization", &token.token);
+ req.with_body(br#"{ "api_token": { "name": "baz" } }"#);
+
+ let mut response = t_resp!(middle.call(&mut req));
+ let json: ::Bad = ::json(&mut response);
+
+ assert_eq!(response.status.0, 400);
+ assert_contains!(
+ json.errors[0].detail,
+ "cannot use an API token to create a new API token"
+ );
+}
+
+#[test]
+fn revoke_token_non_existing() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Delete, "/me/tokens/5");
+
+ let user = {
+ let conn = t!(app.diesel_database.get());
+ t!(::new_user("foo").create_or_update(&conn))
+ };
+ ::sign_in_as(&mut req, &user);
+
+ let mut response = ok_resp!(middle.call(&mut req));
+ ::json::(&mut response);
+}
+
+#[test]
+fn revoke_token_doesnt_revoke_other_users_token() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Delete, "/me/tokens");
+
+ // Create one user with a token and sign in with a different user
+ let (user1, token, user2);
+ {
+ let conn = t!(app.diesel_database.get());
+ user1 = t!(::new_user("foo").create_or_update(&conn));
+ token = t!(ApiToken::insert(&conn, user1.id, "bar"));
+ user2 = t!(::new_user("baz").create_or_update(&conn))
+ };
+ ::sign_in_as(&mut req, &user2);
+
+ // List tokens for first user contains the token
+ {
+ let conn = t!(app.diesel_database.get());
+ let tokens = t!(ApiToken::find_for_user(&conn, user1.id));
+ assert_eq!(tokens.len(), 1);
+ assert_eq!(tokens[0].name, token.name);
+ }
+
+ // Try revoke the token as second user
+ {
+ req.with_path(&format!("/me/tokens/{}", token.id));
+
+ let mut response = ok_resp!(middle.call(&mut req));
+ ::json::(&mut response);
+ }
+
+ // List tokens for first user still contains the token
+ {
+ let conn = t!(app.diesel_database.get());
+ let tokens = t!(ApiToken::find_for_user(&conn, user1.id));
+ assert_eq!(tokens.len(), 1);
+ assert_eq!(tokens[0].name, token.name);
+ }
+}
+
+#[test]
+fn revoke_token_success() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Delete, "/me/tokens");
+
+ let (user, token);
+ {
+ let conn = t!(app.diesel_database.get());
+ user = t!(::new_user("foo").create_or_update(&conn));
+ token = t!(ApiToken::insert(&conn, user.id, "bar"));
+ }
+ ::sign_in_as(&mut req, &user);
+
+ // List tokens contains the token
+ {
+ let conn = t!(app.diesel_database.get());
+ let tokens = ApiToken::find_for_user(&conn, user.id).unwrap();
+ assert_eq!(tokens.len(), 1);
+ assert_eq!(tokens[0].name, token.name);
+ }
+
+ // Revoke the token
+ {
+ req.with_path(&format!("/me/tokens/{}", token.id));
+
+ let mut response = ok_resp!(middle.call(&mut req));
+ ::json::(&mut response);
+ }
+
+ // List tokens no longer contains the token
+ {
+ let conn = t!(app.diesel_database.get());
+ let tokens = ApiToken::find_for_user(&conn, user.id).unwrap();
+ assert_eq!(tokens.len(), 0);
+ }
+}
+
+#[test]
+fn token_gives_access_to_me() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Get, "/me");
+
+ let response = t_resp!(middle.call(&mut req));
+ assert_eq!(response.status.0, 403);
+
+ let (user, token);
+ {
+ let conn = t!(app.diesel_database.get());
+ user = t!(::new_user("foo").create_or_update(&conn));
+ token = t!(ApiToken::insert(&conn, user.id, "bar"));
+ }
+ req.header("Authorization", &token.token);
+
+ let mut response = ok_resp!(middle.call(&mut req));
+ let json: ::user::UserShowResponse = ::json(&mut response);
+
+ assert_eq!(json.user.email, user.email);
+}
+
+#[test]
+fn using_token_updates_last_used_at() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Get, "/me");
+ let response = t_resp!(middle.call(&mut req));
+ assert_eq!(response.status.0, 403);
+
+ let (user, token);
+ {
+ let conn = t!(app.diesel_database.get());
+ user = t!(::new_user("foo").create_or_update(&conn));
+ token = t!(ApiToken::insert(&conn, user.id, "bar"));
+ }
+ req.header("Authorization", &token.token);
+ assert!(token.last_used_at.is_none());
+
+ ok_resp!(middle.call(&mut req));
+
+ let token = {
+ let conn = t!(app.diesel_database.get());
+ t!(ApiToken::find_for_user(&conn, user.id)).pop().unwrap()
+ };
+ assert!(token.last_used_at.is_some());
+
+ // Would check that it updates the timestamp here, but the timestamp is
+ // based on the start of the database transaction so it doesn't work in
+ // this test framework.
+}
+
+#[test]
+fn deleted_token_does_not_give_access_to_me() {
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Get, "/me");
+
+ let response = t_resp!(middle.call(&mut req));
+ assert_eq!(response.status.0, 403);
+
+ let token;
+ {
+ let conn = t!(app.diesel_database.get());
+ let user = t!(::new_user("foo").create_or_update(&conn));
+ token = t!(ApiToken::insert(&conn, user.id, "bar"));
+ t!(ApiToken::delete(&conn, user.id, token.id));
+ }
+ req.header("Authorization", &token.token);
+
+ let response = t_resp!(middle.call(&mut req));
+ assert_eq!(response.status.0, 403);
+}
diff --git a/src/tests/user.rs b/src/tests/user.rs
index c2f78b473ce..c78405a5dc3 100644
--- a/src/tests/user.rs
+++ b/src/tests/user.rs
@@ -1,7 +1,9 @@
+use std::sync::atomic::Ordering;
+
use conduit::{Handler, Method};
use cargo_registry::Model;
-use cargo_registry::db::RequestTransaction;
+use cargo_registry::token::ApiToken;
use cargo_registry::krate::EncodableCrate;
use cargo_registry::user::{User, NewUser, EncodableUser};
use cargo_registry::version::EncodableVersion;
@@ -12,13 +14,8 @@ struct AuthResponse {
state: String,
}
#[derive(RustcDecodable)]
-struct MeResponse {
- user: EncodableUser,
- api_token: String,
-}
-#[derive(RustcDecodable)]
-struct UserShowResponse {
- user: EncodableUser,
+pub struct UserShowResponse {
+ pub user: EncodableUser,
}
#[test]
@@ -46,7 +43,6 @@ fn user_insert() {
let tx = t!(conn.transaction());
let user = t!(User::find_or_insert(&tx, 1, "foo", None, None, None, "bar"));
- assert_eq!(t!(User::find_by_api_token(&tx, &user.api_token)), user);
assert_eq!(t!(User::find(&tx, user.id)), user);
assert_eq!(
@@ -72,10 +68,11 @@ fn me() {
assert_eq!(response.status.0, 403);
let user = ::mock_user(&mut req, ::user("foo"));
+
let mut response = ok_resp!(middle.call(&mut req));
- let json: MeResponse = ::json(&mut response);
+ let json: UserShowResponse = ::json(&mut response);
+
assert_eq!(json.user.email, user.email);
- assert_eq!(json.api_token, user.api_token);
}
#[test]
@@ -101,18 +98,6 @@ fn show() {
assert_eq!(Some("https://github.com/bar".into()), json.user.url);
}
-#[test]
-fn reset_token() {
- let (_b, app, middle) = ::app();
- let mut req = ::req(app, Method::Put, "/me/reset_token");
- let user = User::find_or_insert(req.tx().unwrap(), 1, "foo", None, None, None, "bar").unwrap();
- ::sign_in_as(&mut req, &user);
- ok_resp!(middle.call(&mut req));
-
- let u2 = User::find(req.tx().unwrap(), user.id).unwrap();
- assert!(u2.api_token != user.api_token);
-}
-
#[test]
fn crates_by_user_id() {
let (_b, app, middle) = ::app();
@@ -220,3 +205,21 @@ fn following() {
bad_resp!(middle.call(req.with_query("page=0")));
}
+
+#[test]
+fn updating_existing_user_doesnt_change_api_token() {
+ let (_b, app, _middle) = ::app();
+ let conn = t!(app.diesel_database.get());
+
+ let gh_user_id = ::NEXT_ID.fetch_add(1, Ordering::SeqCst) as i32;
+
+ let original_user =
+ t!(NewUser::new(gh_user_id, "foo", None, None, None, "foo_token").create_or_update(&conn));
+ let token = t!(ApiToken::insert(&conn, original_user.id, "foo"));
+
+ t!(NewUser::new(gh_user_id, "bar", None, None, None, "bar_token").create_or_update(&conn));
+ let user = t!(User::find_by_api_token(&conn, &token.token));
+
+ assert_eq!("bar", user.gh_login);
+ assert_eq!("bar_token", user.gh_access_token);
+}
diff --git a/src/token.rs b/src/token.rs
new file mode 100644
index 00000000000..99e399e9deb
--- /dev/null
+++ b/src/token.rs
@@ -0,0 +1,207 @@
+use diesel;
+use diesel::prelude::*;
+use diesel::pg::PgConnection;
+use conduit::{Request, Response};
+use time::Timespec;
+use conduit_router::RequestParams;
+use rustc_serialize::json;
+
+use db::RequestTransaction;
+use user::{RequestUser, AuthenticationSource};
+use util::{RequestUtils, CargoResult, ChainError, bad_request, read_fill};
+use schema::api_tokens;
+
+/// The model representing a row in the `api_tokens` database table.
+#[derive(Clone, Debug, PartialEq, Eq, Identifiable, Queryable)]
+pub struct ApiToken {
+ pub id: i32,
+ pub user_id: i32,
+ pub token: String,
+ pub name: String,
+ pub created_at: Timespec,
+ pub last_used_at: Option,
+}
+
+/// The serialization format for the `ApiToken` model without its token value.
+#[derive(RustcDecodable, RustcEncodable)]
+pub struct EncodableApiToken {
+ pub id: i32,
+ pub name: String,
+ pub created_at: String,
+ pub last_used_at: Option,
+}
+
+/// The serialization format for the `ApiToken` model with its token value.
+/// This should only be used when initially creating a new token to minimize
+/// the chance of token leaks.
+#[derive(RustcDecodable, RustcEncodable)]
+pub struct EncodableApiTokenWithToken {
+ pub id: i32,
+ pub name: String,
+ pub token: String,
+ pub created_at: String,
+ pub last_used_at: Option,
+}
+
+impl ApiToken {
+ /// Generates a new named API token for a user
+ pub fn insert(conn: &PgConnection, user_id: i32, name: &str) -> CargoResult {
+ #[table_name = "api_tokens"]
+ #[derive(Insertable, AsChangeset)]
+ struct NewApiToken<'a> {
+ user_id: i32,
+ name: &'a str,
+ }
+
+ diesel::insert(&NewApiToken {
+ user_id: user_id,
+ name: name,
+ }).into(api_tokens::table)
+ .get_result::(conn)
+ .map_err(From::from)
+ }
+
+ /// Deletes the provided API token if it belongs to the provided user
+ pub fn delete(conn: &PgConnection, user_id: i32, id: i32) -> CargoResult<()> {
+ diesel::delete(api_tokens::table.find(id).filter(
+ api_tokens::user_id.eq(user_id),
+ )).execute(conn)?;
+ Ok(())
+ }
+
+ pub fn find_for_user(conn: &PgConnection, user_id: i32) -> CargoResult> {
+ api_tokens::table
+ .filter(api_tokens::user_id.eq(user_id))
+ .order(api_tokens::created_at.desc())
+ .load::(conn)
+ .map_err(From::from)
+ }
+
+ pub fn count_for_user(conn: &PgConnection, user_id: i32) -> CargoResult {
+ api_tokens::table
+ .filter(api_tokens::user_id.eq(user_id))
+ .count()
+ .get_result::(conn)
+ .map(|count| count as u64)
+ .map_err(From::from)
+ }
+
+ /// Converts this `ApiToken` model into an `EncodableApiToken` for JSON
+ /// serialization.
+ pub fn encodable(self) -> EncodableApiToken {
+ EncodableApiToken {
+ id: self.id,
+ name: self.name,
+ created_at: ::encode_time(self.created_at),
+ last_used_at: self.last_used_at.map(::encode_time),
+ }
+ }
+
+ /// Converts this `ApiToken` model into an `EncodableApiToken` including
+ /// the actual token value for JSON serialization. This should only be
+ /// used when initially creating a new token to minimize the chance of
+ /// token leaks.
+ pub fn encodable_with_token(self) -> EncodableApiTokenWithToken {
+ EncodableApiTokenWithToken {
+ id: self.id,
+ name: self.name,
+ token: self.token,
+ created_at: ::encode_time(self.created_at),
+ last_used_at: self.last_used_at.map(::encode_time),
+ }
+ }
+}
+
+/// Handles the `GET /me/tokens` route.
+pub fn list(req: &mut Request) -> CargoResult {
+ let db_conn = &*req.db_conn()?;
+ let user_id = req.user()?.id;
+ let tokens = ApiToken::find_for_user(db_conn, user_id)?
+ .into_iter()
+ .map(ApiToken::encodable)
+ .collect();
+ #[derive(RustcEncodable)]
+ struct R {
+ api_tokens: Vec,
+ }
+ Ok(req.json(&R { api_tokens: tokens }))
+}
+
+/// Handles the `POST /me/tokens` route.
+pub fn new(req: &mut Request) -> CargoResult {
+ /// The incoming serialization format for the `ApiToken` model.
+ #[derive(RustcDecodable, RustcEncodable)]
+ struct NewApiToken {
+ name: String,
+ }
+
+ /// The incoming serialization format for the `ApiToken` model.
+ #[derive(RustcDecodable, RustcEncodable)]
+ struct NewApiTokenRequest {
+ api_token: NewApiToken,
+ }
+
+ if req.authentication_source()? != AuthenticationSource::SessionCookie {
+ return Err(bad_request(
+ "cannot use an API token to create a new API token",
+ ));
+ }
+
+ let max_post_size = 2000;
+ let length = req.content_length().chain_error(|| {
+ bad_request("missing header: Content-Length")
+ })?;
+
+ if length > max_post_size {
+ return Err(bad_request(&format!("max post size is: {}", max_post_size)));
+ }
+
+ let mut json = vec![0; length as usize];
+ read_fill(req.body(), &mut json)?;
+
+ let json = String::from_utf8(json).map_err(|_| {
+ bad_request(&"json body was not valid utf-8")
+ })?;
+
+ let new: NewApiTokenRequest = json::decode(&json).map_err(|e| {
+ bad_request(&format!("invalid new token request: {:?}", e))
+ })?;
+
+ let name = &new.api_token.name;
+ if name.len() < 1 {
+ return Err(bad_request("name must have a value"));
+ }
+
+ let user = req.user()?;
+
+ let max_token_per_user = 500;
+ let count = ApiToken::count_for_user(&*req.db_conn()?, user.id)?;
+ if count >= max_token_per_user {
+ return Err(bad_request(&format!(
+ "maximum tokens per user is: {}",
+ max_token_per_user
+ )));
+ }
+
+ let api_token = ApiToken::insert(&*req.db_conn()?, user.id, name)?;
+
+ #[derive(RustcEncodable)]
+ struct R {
+ api_token: EncodableApiTokenWithToken,
+ }
+ Ok(req.json(&R { api_token: api_token.encodable_with_token() }))
+}
+
+/// Handles the `DELETE /me/tokens/:id` route.
+pub fn revoke(req: &mut Request) -> CargoResult {
+ let user = req.user()?;
+ let id = req.params()["id"].parse().map_err(|e| {
+ bad_request(&format!("invalid token id: {:?}", e))
+ })?;
+
+ ApiToken::delete(&*req.db_conn()?, user.id, id)?;
+
+ #[derive(RustcEncodable)]
+ struct R {}
+ Ok(req.json(&R {}))
+}
diff --git a/src/user/middleware.rs b/src/user/middleware.rs
index 5c84c83aaf9..9eea3dca85f 100644
--- a/src/user/middleware.rs
+++ b/src/user/middleware.rs
@@ -11,6 +11,12 @@ use util::errors::{CargoResult, Unauthorized, ChainError, std_error};
pub struct Middleware;
+#[derive(Debug, Copy, Clone, Eq, PartialEq)]
+pub enum AuthenticationSource {
+ SessionCookie,
+ ApiToken,
+}
+
impl conduit_middleware::Middleware for Middleware {
fn before(&self, req: &mut Request) -> Result<(), Box> {
// Check if the request has a session cookie with a `user_id` property inside
@@ -18,45 +24,37 @@ impl conduit_middleware::Middleware for Middleware {
req.session().get("user_id").and_then(|s| s.parse().ok())
};
- let user = match id {
-
- // `user_id` was found on the session
- Some(id) => {
-
- // Look for a user in the database with the given `user_id`
- match User::find(req.tx().map_err(std_error)?, id) {
- Ok(user) => user,
- Err(..) => return Ok(()),
- }
+ if let Some(id) = id {
+ // If it did, look for a user in the database with the given `user_id`
+ if let Ok(user) = User::find(req.tx().map_err(std_error)?, id) {
+ // Attach the `User` model from the database to the request
+ req.mut_extensions().insert(user);
+ req.mut_extensions().insert(
+ AuthenticationSource::SessionCookie,
+ );
}
-
- // `user_id` was *not* found on the session
- None => {
-
- // Look for an `Authorization` header on the request
- match req.headers().find("Authorization") {
- Some(headers) => {
-
- // Look for a user in the database with a matching API token
- let tx = req.tx().map_err(std_error)?;
- match User::find_by_api_token(tx, headers[0]) {
- Ok(user) => user,
- Err(..) => return Ok(()),
- }
- }
- None => return Ok(()),
- }
+ } else {
+ // Otherwise, look for an `Authorization` header on the request
+ // and try to find a user in the database with a matching API token
+ let user = if let Some(headers) = req.headers().find("Authorization") {
+ User::find_by_api_token(&*req.db_conn().map_err(std_error)?, headers[0]).ok()
+ } else {
+ None
+ };
+ if let Some(user) = user {
+ // Attach the `User` model from the database to the request
+ req.mut_extensions().insert(user);
+ req.mut_extensions().insert(AuthenticationSource::ApiToken);
}
- };
+ }
- // Attach the `User` model from the database to the request
- req.mut_extensions().insert(user);
Ok(())
}
}
pub trait RequestUser {
fn user(&self) -> CargoResult<&User>;
+ fn authentication_source(&self) -> CargoResult;
}
impl<'a> RequestUser for Request + 'a {
@@ -65,4 +63,11 @@ impl<'a> RequestUser for Request + 'a {
|| Unauthorized,
)
}
+
+ fn authentication_source(&self) -> CargoResult {
+ self.extensions()
+ .find::()
+ .cloned()
+ .chain_error(|| Unauthorized)
+ }
}
diff --git a/src/user/mod.rs b/src/user/mod.rs
index 7def99dea95..abf1002f043 100644
--- a/src/user/mod.rs
+++ b/src/user/mod.rs
@@ -19,7 +19,7 @@ use {http, Model, Version};
use owner::{Owner, OwnerKind, CrateOwner};
use krate::Crate;
-pub use self::middleware::{Middleware, RequestUser};
+pub use self::middleware::{Middleware, RequestUser, AuthenticationSource};
pub mod middleware;
@@ -29,7 +29,6 @@ pub struct User {
pub id: i32,
pub email: Option,
pub gh_access_token: String,
- pub api_token: String,
pub gh_login: String,
pub name: Option,
pub gh_avatar: Option,
@@ -105,16 +104,16 @@ impl User {
}
/// Queries the database for a user with a certain `api_token` value.
- pub fn find_by_api_token(conn: &GenericConnection, token: &str) -> CargoResult {
- let stmt = conn.prepare(
- "SELECT * FROM users \
- WHERE api_token = $1 LIMIT 1",
- )?;
- let rows = stmt.query(&[&token])?;
- rows.iter()
- .next()
- .map(|r| Model::from_row(&r))
- .chain_error(|| NotFound)
+ pub fn find_by_api_token(conn: &PgConnection, token_: &str) -> CargoResult {
+ use diesel::update;
+ use diesel::expression::now;
+ use schema::api_tokens::dsl::{api_tokens, token, user_id, last_used_at};
+ use schema::users::dsl::{users, id};
+ let user_id_ = update(api_tokens.filter(token.eq(token_)))
+ .set(last_used_at.eq(now.nullable()))
+ .returning(user_id)
+ .get_result::(conn)?;
+ Ok(users.filter(id.eq(user_id_)).get_result(conn)?)
}
/// Updates a user or inserts a new user into the database.
@@ -203,7 +202,6 @@ impl Model for User {
id: row.get("id"),
email: row.get("email"),
gh_access_token: row.get("gh_access_token"),
- api_token: row.get("api_token"),
gh_login: row.get("gh_login"),
gh_id: row.get("gh_id"),
name: row.get("name"),
@@ -336,41 +334,13 @@ pub fn logout(req: &mut Request) -> CargoResult {
Ok(req.json(&true))
}
-/// Handles the `GET /me/reset_token` route.
-pub fn reset_token(req: &mut Request) -> CargoResult {
- let user = req.user()?;
-
- let conn = req.tx()?;
- let rows = conn.query(
- "UPDATE users SET api_token = DEFAULT \
- WHERE id = $1 RETURNING api_token",
- &[&user.id],
- )?;
- let token = rows.iter().next().map(|r| r.get("api_token")).chain_error(
- || NotFound,
- )?;
-
- #[derive(RustcEncodable)]
- struct R {
- api_token: String,
- }
- Ok(req.json(&R { api_token: token }))
-}
-
/// Handles the `GET /me` route.
pub fn me(req: &mut Request) -> CargoResult {
- let user = req.user()?;
-
#[derive(RustcEncodable)]
struct R {
user: EncodableUser,
- api_token: String,
}
- let token = user.api_token.clone();
- Ok(req.json(&R {
- user: user.clone().encodable(),
- api_token: token,
- }))
+ Ok(req.json(&R { user: req.user()?.clone().encodable() }))
}
/// Handles the `GET /users/:user_id` route.
@@ -450,54 +420,3 @@ pub fn updates(req: &mut Request) -> CargoResult {
meta: Meta { more: more },
}))
}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use diesel::pg::PgConnection;
- use dotenv::dotenv;
- use std::env;
-
- fn connection() -> PgConnection {
- let _ = dotenv();
- let database_url =
- env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set to run tests");
- let conn = PgConnection::establish(&database_url).unwrap();
- conn.begin_test_transaction().unwrap();
- conn
- }
-
- #[test]
- fn new_users_have_different_api_tokens() {
- let conn = connection();
- let user1 = NewUser::new(1, "foo", None, None, None, "foo")
- .create_or_update(&conn)
- .unwrap();
- let user2 = NewUser::new(2, "bar", None, None, None, "bar")
- .create_or_update(&conn)
- .unwrap();
-
- assert_ne!(user1.id, user2.id);
- assert_ne!(user1.api_token, user2.api_token);
- assert_eq!(32, user1.api_token.len());
- }
-
- #[test]
- fn updating_existing_user_doesnt_change_api_token() {
- let conn = connection();
- let user_after_insert = NewUser::new(1, "foo", None, None, None, "foo")
- .create_or_update(&conn)
- .unwrap();
- let original_token = user_after_insert.api_token;
- NewUser::new(1, "bar", None, None, None, "bar_token")
- .create_or_update(&conn)
- .unwrap();
- let mut users = users::table.load::(&conn).unwrap();
- assert_eq!(1, users.len());
- let user = users.pop().unwrap();
-
- assert_eq!("bar", user.gh_login);
- assert_eq!("bar_token", user.gh_access_token);
- assert_eq!(original_token, user.api_token);
- }
-}
diff --git a/src/util/errors.rs b/src/util/errors.rs
index a3737e29cf7..2982154b202 100644
--- a/src/util/errors.rs
+++ b/src/util/errors.rs
@@ -271,6 +271,28 @@ impl fmt::Display for Unauthorized {
}
}
+struct BadRequest(String);
+
+impl CargoError for BadRequest {
+ fn description(&self) -> &str {
+ self.0.as_ref()
+ }
+
+ fn response(&self) -> Option {
+ let mut response = json_response(&Bad {
+ errors: vec![StringError { detail: self.0.clone() }],
+ });
+ response.status = (400, "Bad Request");
+ Some(response)
+ }
+}
+
+impl fmt::Display for BadRequest {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
pub fn internal_error(error: &str, detail: &str) -> Box {
Box::new(ConcreteCargoError {
description: error.to_string(),
@@ -298,6 +320,17 @@ pub fn human(error: &S) -> Box {
})
}
+/// This is intended to be used for errors being sent back to the Ember
+/// frontend, not to cargo as cargo does not handle non-200 response codes well
+/// (see https://github.com/rust-lang/cargo/issues/3995), but Ember requires
+/// non-200 response codes for its stores to work properly.
+///
+/// Since this is going back to the UI these errors are treated the same as
+/// `human` errors, other than the HTTP status code.
+pub fn bad_request(error: &S) -> Box {
+ Box::new(BadRequest(error.to_string()))
+}
+
pub fn std_error(e: Box) -> Box {
#[derive(Debug)]
struct E(Box);
diff --git a/src/util/mod.rs b/src/util/mod.rs
index 02a8d2e4696..32dfae580f3 100644
--- a/src/util/mod.rs
+++ b/src/util/mod.rs
@@ -12,7 +12,7 @@ use conduit_router::{RouteBuilder, RequestParams};
use db::RequestTransaction;
use self::errors::NotFound;
-pub use self::errors::{CargoError, CargoResult, internal, human, internal_error};
+pub use self::errors::{CargoError, CargoResult, internal, human, bad_request, internal_error};
pub use self::errors::{ChainError, std_error};
pub use self::hasher::HashingReader;
pub use self::head::Head;