diff --git a/awx/main/authentication.py b/awx/main/authentication.py index 6e01a0832c..68f273a907 100644 --- a/awx/main/authentication.py +++ b/awx/main/authentication.py @@ -6,7 +6,36 @@ from rest_framework import authentication from rest_framework import exceptions # AWX -from awx.main.models import Job +from awx.main.models import Job, AuthToken + +class TokenAuthentication(authentication.TokenAuthentication): + ''' + Custom token authentication using tokens that expire and are associated + with parameters specific to the request. + ''' + + model = AuthToken + + def authenticate(self, request): + self.request = request + return super(TokenAuthentication, self).authenticate(request) + + def authenticate_credentials(self, key): + try: + request_hash = self.model.get_request_hash(self.request) + token = self.model.objects.get(key=key, request_hash=request_hash) + except self.model.DoesNotExist: + raise exceptions.AuthenticationFailed('Invalid token') + + if token.expired: + raise exceptions.AuthenticationFailed('Token is expired') + + if not token.user.is_active: + raise exceptions.AuthenticationFailed('User inactive or deleted') + + token.refresh() + + return (token.user, token) class JobTaskAuthentication(authentication.BaseAuthentication): ''' diff --git a/awx/main/base_views.py b/awx/main/base_views.py index 7cfe920013..5858d7cdaa 100644 --- a/awx/main/base_views.py +++ b/awx/main/base_views.py @@ -13,6 +13,7 @@ from django.template.loader import render_to_string from django.utils.timezone import now # Django REST Framework +from rest_framework.authentication import get_authorization_header from rest_framework.exceptions import PermissionDenied from rest_framework import generics from rest_framework.response import Response @@ -30,7 +31,24 @@ __all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'ListCreateAPIView', 'RetrieveUpdateAPIView', 'RetrieveUpdateDestroyAPIView'] class APIView(views.APIView): - + + def get_authenticate_header(self, request): + """ + Determine the WWW-Authenticate header to use for 401 responses. Try to + use the request header as an indication for which authentication method + was attempted. + """ + for authenticator in self.get_authenticators(): + resp_hdr = authenticator.authenticate_header(request) + if not resp_hdr: + continue + req_hdr = get_authorization_header(request) + if not req_hdr: + continue + if resp_hdr.split()[0] and resp_hdr.split()[0] == req_hdr.split()[0]: + return resp_hdr + return super(APIView, self).get_authenticate_header(request) + def get_description_context(self): return { 'docstring': type(self).__doc__ or '', diff --git a/awx/main/migrations/0012_v13_changes.py b/awx/main/migrations/0012_v13_changes.py new file mode 100644 index 0000000000..eb3bf704a4 --- /dev/null +++ b/awx/main/migrations/0012_v13_changes.py @@ -0,0 +1,330 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'AuthToken' + db.create_table(u'main_authtoken', ( + ('key', self.gf('django.db.models.fields.CharField')(max_length=40, primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='auth_tokens', to=orm['auth.User'])), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), + ('expires', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), + ('request_hash', self.gf('django.db.models.fields.CharField')(default='', max_length=40, blank=True)), + )) + db.send_create_signal(u'main', ['AuthToken']) + + + def backwards(self, orm): + # Deleting model 'AuthToken' + db.delete_table(u'main_authtoken') + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'main.authtoken': { + 'Meta': {'object_name': 'AuthToken'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'expires': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '40', 'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'request_hash': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '40', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'auth_tokens'", 'to': u"orm['auth.User']"}) + }, + 'main.credential': { + 'Meta': {'object_name': 'Credential'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'credential\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'credential\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'ssh_key_data': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'ssh_key_unlock': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'ssh_password': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'ssh_username': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'sudo_password': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'sudo_username': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'team': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credentials'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Team']", 'blank': 'True', 'null': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credentials'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': u"orm['auth.User']", 'blank': 'True', 'null': 'True'}) + }, + 'main.group': { + 'Meta': {'unique_together': "(('name', 'inventory'),)", 'object_name': 'Group'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'group\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'has_active_failures': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'groups'", 'blank': 'True', 'to': "orm['main.Host']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'groups'", 'to': "orm['main.Inventory']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'group\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'parents': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'children'", 'blank': 'True', 'to': "orm['main.Group']"}), + 'variables': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}) + }, + 'main.host': { + 'Meta': {'unique_together': "(('name', 'inventory'),)", 'object_name': 'Host'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'host\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'has_active_failures': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hosts'", 'to': "orm['main.Inventory']"}), + 'last_job': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hosts_as_last_job+'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Job']", 'blank': 'True', 'null': 'True'}), + 'last_job_host_summary': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hosts_as_last_job_summary+'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': u"orm['main.JobHostSummary']", 'blank': 'True', 'null': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'host\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'variables': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}) + }, + 'main.inventory': { + 'Meta': {'unique_together': "(('name', 'organization'),)", 'object_name': 'Inventory'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'inventory\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'has_active_failures': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'inventory\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'organization': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'inventories'", 'to': "orm['main.Organization']"}), + 'variables': ('django.db.models.fields.TextField', [], {'default': "''", 'null': 'True', 'blank': 'True'}) + }, + 'main.job': { + 'Meta': {'object_name': 'Job'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'cancel_flag': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'celery_task_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '100', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'job\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Credential']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'extra_vars': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'forks': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'jobs'", 'blank': 'True', 'through': u"orm['main.JobHostSummary']", 'to': "orm['main.Host']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'job_args': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_cwd': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_env': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + 'job_tags': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.JobTemplate']", 'blank': 'True', 'null': 'True'}), + 'job_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'launch_type': ('django.db.models.fields.CharField', [], {'default': "'manual'", 'max_length': '20'}), + 'limit': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'job\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'playbook': ('django.db.models.fields.CharField', [], {'max_length': '1024'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['main.Project']"}), + 'result_stdout': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'result_traceback': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'new'", 'max_length': '20'}), + 'verbosity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}) + }, + 'main.jobevent': { + 'Meta': {'ordering': "('pk',)", 'object_name': 'JobEvent'}, + 'changed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now_add': 'True', 'blank': 'True'}), + 'event': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'event_data': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'host': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_events_as_primary_host'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Host']", 'blank': 'True', 'null': 'True'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'job_events'", 'blank': 'True', 'to': "orm['main.Host']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'job': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_events'", 'to': "orm['main.Job']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'children'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.JobEvent']", 'blank': 'True', 'null': 'True'}), + 'play': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'task': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}) + }, + u'main.jobhostsummary': { + 'Meta': {'ordering': "('-pk',)", 'unique_together': "[('job', 'host')]", 'object_name': 'JobHostSummary'}, + 'changed': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now_add': 'True', 'blank': 'True'}), + 'dark': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'host': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_host_summaries'", 'to': "orm['main.Host']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'job': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_host_summaries'", 'to': "orm['main.Job']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'ok': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'processed': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'skipped': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + 'main.jobtemplate': { + 'Meta': {'object_name': 'JobTemplate'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'jobtemplate\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_templates'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'extra_vars': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'forks': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}), + 'host_config_key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_templates'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'job_tags': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'limit': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'jobtemplate\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'playbook': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_templates'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['main.Project']"}), + 'verbosity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}) + }, + 'main.organization': { + 'Meta': {'object_name': 'Organization'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'admins': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'admin_of_organizations'", 'blank': 'True', 'to': u"orm['auth.User']"}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'organization\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'organization\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'projects': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'organizations'", 'blank': 'True', 'to': u"orm['main.Project']"}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'organizations'", 'blank': 'True', 'to': u"orm['auth.User']"}) + }, + 'main.permission': { + 'Meta': {'object_name': 'Permission'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'permission\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'permission\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'permission_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['main.Project']"}), + 'team': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Team']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}) + }, + u'main.project': { + 'Meta': {'object_name': 'Project'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'project\', \'app_label\': u\'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'current_update': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'project_as_current_update+'", 'null': 'True', 'to': "orm['main.ProjectUpdate']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_update': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'project_as_last_update+'", 'null': 'True', 'to': "orm['main.ProjectUpdate']"}), + 'last_update_failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'local_path': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'project\', \'app_label\': u\'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'scm_branch': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '256', 'null': 'True', 'blank': 'True'}), + 'scm_clean': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_delete_on_next_update': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_delete_on_update': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_key_data': ('django.db.models.fields.TextField', [], {'default': "''", 'null': 'True', 'blank': 'True'}), + 'scm_key_unlock': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'null': 'True', 'blank': 'True'}), + 'scm_password': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'null': 'True', 'blank': 'True'}), + 'scm_type': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '8', 'null': 'True', 'blank': 'True'}), + 'scm_update_on_launch': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'null': 'True', 'blank': 'True'}), + 'scm_username': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '256', 'null': 'True', 'blank': 'True'}) + }, + 'main.projectupdate': { + 'Meta': {'object_name': 'ProjectUpdate'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'cancel_flag': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'celery_task_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '100', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'projectupdate\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'job_args': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_cwd': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_env': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'projectupdate\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'project_updates'", 'to': u"orm['main.Project']"}), + 'result_stdout': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'result_traceback': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'new'", 'max_length': '20'}) + }, + 'main.team': { + 'Meta': {'object_name': 'Team'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'team\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'team\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'organization': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'teams'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Organization']"}), + 'projects': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'teams'", 'blank': 'True', 'to': u"orm['main.Project']"}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'teams'", 'blank': 'True', 'to': u"orm['auth.User']"}) + }, + u'taggit.tag': { + 'Meta': {'object_name': 'Tag'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'}) + }, + u'taggit.taggeditem': { + 'Meta': {'object_name': 'TaggedItem'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'taggit_taggeditem_tagged_items'", 'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'taggit_taggeditem_items'", 'to': u"orm['taggit.Tag']"}) + } + } + + complete_apps = ['main'] \ No newline at end of file diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index e641a399e4..5f068ae4f5 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -3,12 +3,14 @@ # Python import datetime +import hashlib import hmac import json import logging import os import re import shlex +import uuid # PyYAML import yaml @@ -38,6 +40,7 @@ from awx.main.utils import encrypt_field, decrypt_field __all__ = ['PrimordialModel', 'Organization', 'Team', 'Project', 'ProjectUpdate', 'Credential', 'Inventory', 'Host', 'Group', 'Permission', 'JobTemplate', 'Job', 'JobHostSummary', 'JobEvent', + 'AuthToken', 'PERM_INVENTORY_ADMIN', 'PERM_INVENTORY_READ', 'PERM_INVENTORY_WRITE', 'PERM_INVENTORY_DEPLOY', 'PERM_INVENTORY_CHECK', 'JOB_STATUS_CHOICES'] @@ -1717,6 +1720,61 @@ class JobEvent(models.Model): if host_summary_changed: host_summary.save() +class AuthToken(models.Model): + ''' + Custom authentication tokens per user with expiration and request-specific + data. + ''' + + key = models.CharField(max_length=40, primary_key=True) + user = models.ForeignKey('auth.User', related_name='auth_tokens', + on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + expires = models.DateTimeField(default=now) + request_hash = models.CharField(max_length=40, blank=True, default='') + + @classmethod + def get_request_hash(cls, request): + h = hashlib.sha1() + h.update(settings.SECRET_KEY) + for header in settings.REMOTE_HOST_HEADERS: + value = request.META.get(header, '').strip() + if value: + h.update(value) + h.update(request.META.get('HTTP_USER_AGENT', '')) + return h.hexdigest() + + def save(self, *args, **kwargs): + if not self.pk: + self.refresh(save=False) + if not self.key: + self.key = self.generate_key() + return super(AuthToken, self).save(*args, **kwargs) + + def refresh(self, save=True): + if not self.pk or not self.expired: + self.expires = now() + datetime.timedelta(seconds=settings.AUTH_TOKEN_EXPIRATION) + if save: + self.save() + + def invalidate(self, save=True): + if not self.expired: + self.expires = now() - datetime.timedelta(seconds=1) + if save: + self.save() + + def generate_key(self): + unique = uuid.uuid4() + return hmac.new(unique.bytes, digestmod=hashlib.sha1).hexdigest() + + @property + def expired(self): + return bool(self.expires < now()) + + def __unicode__(self): + return self.key + # TODO: reporting (MPD) # Add mark_inactive method to User model. diff --git a/awx/main/serializers.py b/awx/main/serializers.py index fcb8edae36..81c783163e 100644 --- a/awx/main/serializers.py +++ b/awx/main/serializers.py @@ -11,6 +11,7 @@ import urlparse import yaml # Django +from django.contrib.auth import authenticate from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.core.exceptions import ObjectDoesNotExist @@ -677,3 +678,24 @@ class JobEventSerializer(BaseSerializer): if obj.hosts.count(): res['hosts'] = reverse('main:job_event_hosts_list', args=(obj.pk,)) return res + +class AuthTokenSerializer(serializers.Serializer): + + username = serializers.CharField() + password = serializers.CharField() + + def validate(self, attrs): + username = attrs.get('username') + password = attrs.get('password') + + if username and password: + user = authenticate(username=username, password=password) + if user: + if not user.is_active: + raise serializers.ValidationError('User account is disabled.') + attrs['user'] = user + return attrs + else: + raise serializers.ValidationError('Unable to login with provided credentials.') + else: + raise serializers.ValidationError('Must include "username" and "password"') diff --git a/awx/main/signals.py b/awx/main/signals.py index 81a2a4b26c..a4fb67e721 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -6,14 +6,9 @@ import logging import threading # Django -from django.contrib.auth.models import User -from django.db import DatabaseError from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed from django.dispatch import receiver -# Django-REST-Framework -from rest_framework.authtoken.models import Token - # AWX from awx.main.models import * @@ -21,18 +16,6 @@ __all__ = [] logger = logging.getLogger('awx.main.signals') -@receiver(post_save, sender=User) -def create_auth_token_for_user(sender, **kwargs): - instance = kwargs.get('instance', None) - if instance: - try: - Token.objects.get_or_create(user=instance) - except DatabaseError: - pass - # Only fails when creating a new superuser from syncdb on a - # new database (before migrate has been called). - - # Update has_active_failures for inventory/groups when a Host/Group is deleted # or marked inactive, when a Host-Group or Group-Group relationship is updated, # or when a Job is deleted or marked inactive. diff --git a/awx/main/templates/main/auth_token_view.md b/awx/main/templates/main/auth_token_view.md index 0ea406bdcc..0d3bad70bf 100644 --- a/awx/main/templates/main/auth_token_view.md +++ b/awx/main/templates/main/auth_token_view.md @@ -10,9 +10,13 @@ Example form data to post (content type is `application/x-www-form-urlencoded`): username=user&password=my%20pass If the username and password provided are valid, the response will contain a -`token` field with the authentication token to use: +`token` field with the authentication token to use and an `expires` field with +the timestamp when the token will expire: - {"token": "8f17825cf08a7efea124f2638f3896f6637f8745"} + { + "token": "8f17825cf08a7efea124f2638f3896f6637f8745", + "expires": "2013-09-05T21:46:35.729Z" + } Otherwise, the response will indicate the error that occurred and return a 4xx status code. @@ -21,3 +25,7 @@ For subsequent requests, pass the token via the HTTP `Authenticate` request header: Authenticate: Token 8f17825cf08a7efea124f2638f3896f6637f8745 + +Each request that uses the token for authentication will refresh its expiration +timestamp and keep it from expiring. A token only expires when it is not used +for the configured timeout interval (default 1800 seconds). diff --git a/awx/main/tests/base.py b/awx/main/tests/base.py index 9e8fc91ca9..fbec910f25 100644 --- a/awx/main/tests/base.py +++ b/awx/main/tests/base.py @@ -57,7 +57,8 @@ class BaseTestMixin(object): user = User.objects.create_superuser(username, "%s@example.com", password) else: user = User.objects.create_user(username, "%s@example.com", password) - self.assertTrue(user.auth_token) + # New user should have no auth tokens by default. + self.assertFalse(user.auth_tokens.count()) self._user_passwords[user.username] = password return user @@ -151,7 +152,8 @@ class BaseTestMixin(object): return ('random', 'combination') def _generic_rest(self, url, data=None, expect=204, auth=None, method=None, - data_type=None, accept=None, remote_addr=None): + data_type=None, accept=None, remote_addr=None, + return_response_object=False): assert method is not None method_name = method.lower() #if method_name not in ('options', 'head', 'get', 'delete'): @@ -188,16 +190,27 @@ class BaseTestMixin(object): assert response.status_code == expect, "expected status %s, got %s for url=%s as auth=%s: %s" % (expect, response.status_code, url, auth, response.content) if method_name == 'head': self.assertFalse(response.content) + #if return_response_object: + # return response if response.status_code not in [ 202, 204, 405 ] and method_name != 'head' and response.content: # no JSON responses in these at least for now, 409 should probably return some (FIXME) if response['Content-Type'].startswith('application/json'): - return json.loads(response.content) + obj = json.loads(response.content) elif response['Content-Type'].startswith('application/yaml'): - return yaml.safe_load(response.content) + obj = yaml.safe_load(response.content) else: self.fail('Unsupport response content type %s' % response['Content-Type']) else: - return None + obj = {} + + # Create a new subclass of object type and attach the response instance + # to it (to allow for checking response headers). + if isinstance(obj, dict): + return type('DICT', (dict,), {'response': response})(obj.items()) + elif isinstance(obj, (tuple, list)): + return type('LIST', (list,), {'response': response})(iter(obj)) + else: + return obj def options(self, url, expect=200, auth=None, accept=None, remote_addr=None): diff --git a/awx/main/tests/commands.py b/awx/main/tests/commands.py index 1daaad259c..04636c3b62 100644 --- a/awx/main/tests/commands.py +++ b/awx/main/tests/commands.py @@ -9,6 +9,7 @@ import StringIO import sys import tempfile import time +import urlparse # Django from django.conf import settings @@ -524,9 +525,15 @@ class InventoryImportTest(BaseCommandMixin, BaseLiveServerTest): self.assertEqual(new_inv.hosts.count(), 0) self.assertEqual(new_inv.groups.count(), 0) # Use our own inventory script as executable file. - os.environ.setdefault('REST_API_URL', self.live_server_url) - os.environ.setdefault('REST_API_TOKEN', - self.super_django_user.auth_token.key) + rest_api_url = self.live_server_url + parts = urlparse.urlsplit(rest_api_url) + username, password = self.get_super_credentials() + netloc = '%s:%s@%s' % (username, password, parts.netloc) + rest_api_url = urlparse.urlunsplit([parts.scheme, netloc, parts.path, + parts.query, parts.fragment]) + os.environ.setdefault('REST_API_URL', rest_api_url) + #os.environ.setdefault('REST_API_TOKEN', + # self.super_django_user.auth_token.key) os.environ['INVENTORY_ID'] = str(old_inv.pk) source = os.path.join(os.path.dirname(__file__), '..', '..', 'scripts', 'inventory.py') diff --git a/awx/main/tests/scripts.py b/awx/main/tests/scripts.py index 0a1c33ffd9..17e9c3a3a2 100644 --- a/awx/main/tests/scripts.py +++ b/awx/main/tests/scripts.py @@ -8,6 +8,7 @@ import StringIO import subprocess import sys import tempfile +import urlparse # Django from django.conf import settings @@ -120,9 +121,15 @@ class InventoryScriptTest(BaseScriptTest): self.groups.extend(groups) def run_inventory_script(self, *args, **options): - os.environ.setdefault('REST_API_URL', self.live_server_url) - os.environ.setdefault('REST_API_TOKEN', - self.super_django_user.auth_token.key) + rest_api_url = self.live_server_url + parts = urlparse.urlsplit(rest_api_url) + username, password = self.get_super_credentials() + netloc = '%s:%s@%s' % (username, password, parts.netloc) + rest_api_url = urlparse.urlunsplit([parts.scheme, netloc, parts.path, + parts.query, parts.fragment]) + os.environ.setdefault('REST_API_URL', rest_api_url) + #os.environ.setdefault('REST_API_TOKEN', + # self.super_django_user.auth_token.key) name = os.path.join(os.path.dirname(__file__), '..', '..', 'scripts', 'inventory.py') return self.run_script(name, *args, **options) diff --git a/awx/main/tests/users.py b/awx/main/tests/users.py index 3a6f9dc492..2c05ad3a2c 100644 --- a/awx/main/tests/users.py +++ b/awx/main/tests/users.py @@ -58,15 +58,81 @@ class UsersTest(BaseTest): # A valid username/password should give us an auth token. data = dict(zip(('username', 'password'), self.get_normal_credentials())) - result = self.post(auth_token_url, data, expect=200, auth=None) - self.assertTrue('token' in result) - self.assertEqual(result['token'], self.normal_django_user.auth_token.key) - auth_token = result['token'] + response = self.post(auth_token_url, data, expect=200, auth=None) + self.assertTrue('token' in response) + self.assertTrue('expires' in response) + self.assertEqual(response['token'], self.normal_django_user.auth_tokens.all()[0].key) + auth_token = response['token'] # Verify we can access our own user information with the auth token. - data = self.get(reverse('main:user_me_list'), expect=200, auth=auth_token) - self.assertEquals(data['results'][0]['username'], 'normal') - self.assertEquals(data['count'], 1) + response = self.get(reverse('main:user_me_list'), expect=200, + auth=auth_token) + self.assertEquals(response['results'][0]['username'], 'normal') + self.assertEquals(response['count'], 1) + + # If we simulate a different remote address, should not be able to use + # the first auth token. + remote_addr = '127.0.0.2' + response = self.get(reverse('main:user_me_list'), expect=401, + auth=auth_token, remote_addr=remote_addr) + self.assertEqual(response['detail'], 'Invalid token') + + # The WWW-Authenticate header should specify Token auth, since that + # auth method was used in the request. + response_header = response.response.get('WWW-Authenticate', '') + self.assertEqual(response_header.split()[0], 'Token') + + # Request a new auth token from the new remote address. + data = dict(zip(('username', 'password'), self.get_normal_credentials())) + response = self.post(auth_token_url, data, expect=200, auth=None, + remote_addr=remote_addr) + self.assertTrue('token' in response) + self.assertTrue('expires' in response) + self.assertEqual(response['token'], self.normal_django_user.auth_tokens.all()[1].key) + auth_token2 = response['token'] + + # Verify we can access our own user information with the second auth + # token from the other remote address. + response = self.get(reverse('main:user_me_list'), expect=200, + auth=auth_token2, remote_addr=remote_addr) + self.assertEquals(response['results'][0]['username'], 'normal') + self.assertEquals(response['count'], 1) + + # The second auth token also can't be used from the first address, but + # the first auth token is still valid from its address. + response = self.get(reverse('main:user_me_list'), expect=401, + auth=auth_token2) + self.assertEqual(response['detail'], 'Invalid token') + response_header = response.response.get('WWW-Authenticate', '') + self.assertEqual(response_header.split()[0], 'Token') + response = self.get(reverse('main:user_me_list'), expect=200, + auth=auth_token) + + # A request without authentication should ask for Basic by default. + response = self.get(reverse('main:user_me_list'), expect=401) + response_header = response.response.get('WWW-Authenticate', '') + self.assertEqual(response_header.split()[0], 'Basic') + + # A request that attempts Basic auth should request Basic auth again. + response = self.get(reverse('main:user_me_list'), expect=401, + auth=('invalid', 'password')) + response_header = response.response.get('WWW-Authenticate', '') + self.assertEqual(response_header.split()[0], 'Basic') + + # Invalidate a key (simulate expiration), now token auth should fail + # with the first token, but still work with the second. + self.normal_django_user.auth_tokens.get(key=auth_token).invalidate() + response = self.get(reverse('main:user_me_list'), expect=401, + auth=auth_token) + self.assertEqual(response['detail'], 'Token is expired') + response = self.get(reverse('main:user_me_list'), expect=200, + auth=auth_token2, remote_addr=remote_addr) + + # Token auth should be denied if the user is inactive. + self.normal_django_user.mark_inactive() + response = self.get(reverse('main:user_me_list'), expect=401, + auth=auth_token2, remote_addr=remote_addr) + self.assertEqual(response['detail'], 'User inactive or deleted') def test_ordinary_user_can_modify_some_fields_about_himself_but_not_all_and_passwords_work(self): diff --git a/awx/main/views.py b/awx/main/views.py index b3b9601247..5382e8893d 100644 --- a/awx/main/views.py +++ b/awx/main/views.py @@ -14,6 +14,7 @@ from django.core.urlresolvers import reverse from django.shortcuts import get_object_or_404, render_to_response from django.template import RequestContext from django.utils.datastructures import SortedDict +from django.utils.timezone import now # Django REST Framework from rest_framework.authtoken.views import ObtainAuthToken @@ -121,10 +122,26 @@ class ApiV1ConfigView(APIView): return Response(data) -class AuthTokenView(ObtainAuthToken, APIView): +class AuthTokenView(APIView): permission_classes = (AllowAny,) - renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + serializer_class = AuthTokenSerializer + model = AuthToken + + def post(self, request): + serializer = self.serializer_class(data=request.DATA) + if serializer.is_valid(): + request_hash = AuthToken.get_request_hash(self.request) + try: + token = AuthToken.objects.filter(user=serializer.object['user'], + request_hash=request_hash, + expires__gt=now())[0] + token.refresh() + except IndexError: + token = AuthToken.objects.create(user=serializer.object['user'], + request_hash=request_hash) + return Response({'token': token.key, 'expires': token.expires}) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class OrganizationList(ListCreateAPIView): diff --git a/awx/scripts/inventory.py b/awx/scripts/inventory.py index 431daf7f88..a03ae5baf0 100755 --- a/awx/scripts/inventory.py +++ b/awx/scripts/inventory.py @@ -96,8 +96,8 @@ class InventoryScript(object): os.getenv('REST_API_TOKEN', '') parts = urlparse.urlsplit(self.base_url) if not (parts.username and parts.password) and not self.auth_token: - raise ValueError('No REST API token or username/password ' - 'specified') + raise ValueError('No username/password specified in REST API ' + 'URL, and no REST API token provided') try: # Command line argument takes precedence over environment # variable. @@ -140,7 +140,8 @@ def main(): parser.add_option('--traceback', action='store_true', help='Raise on exception on error') parser.add_option('-u', '--url', dest='base_url', default='', - help='Base URL to access REST API (can also be specified' + help='Base URL to access REST API, including username ' + 'and password for authentication (can also be specified' ' using REST_API_URL environment variable)') parser.add_option('--authtoken', dest='authtoken', default='', help='Authentication token used to access REST API (can ' diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index d27947be5f..6f0b8320a7 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -127,7 +127,6 @@ INSTALLED_APPS = ( 'django.contrib.staticfiles', 'south', 'rest_framework', - 'rest_framework.authtoken', 'django_extensions', 'djcelery', 'kombu.transport.django', @@ -144,7 +143,7 @@ REST_FRAMEWORK = { 'PAGINATE_BY_PARAM': 'page_size', 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.BasicAuthentication', - 'rest_framework.authentication.TokenAuthentication', + 'awx.main.authentication.TokenAuthentication', 'rest_framework.authentication.SessionAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': ( @@ -166,6 +165,9 @@ REST_FRAMEWORK = { ), } +# Seconds before auth tokens expire. +AUTH_TOKEN_EXPIRATION = 1800 + # If set, serve only minified JS for UI. USE_MINIFIED_JS = False