레일즈 사용자테이블 장고

레일즈에서 만들어진 고객 테이블을 장고에서도 수정없이 사용하는 코드입니다.

구현

먼저 AbstractBaseUser 기반으로 User 클래스를 만듭니다.

from django.contrib.auth.models import BaseUserManager, AbstractBaseUser, PermissionsMixin
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.utils.translation import gettext_lazy as _


class UserManager(BaseUserManager):
    def create_user(self, email, name, password=None):
        if not email:
            raise ValueError(_('Users must have an email address'))

        user = self.model(email=self.normalize_email(email), name=name)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, email, nickname, last_name, first_name, password):
        pass  # ref: createsuperuser 에 password 필드이름이 하드코딩 되어있슴.


class User(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(max_length=70, unique=True)
    name = models.CharField(max_length=70)
    roles = ArrayField(models.CharField(max_length=70), default=[])
    encrypted_password = models.CharField(max_length=128)
    last_login = models.DateTimeField(_('last login'), blank=True, null=True, db_column='last_sign_in_at')
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['name', 'password']

    objects = UserManager()

    class Meta:
        managed = False
        db_table = 'users'  

    @property
    def password(self):
        return f'devise{self.encrypted_password}'

    @password.setter
    def password(self, var):
        self.encrypted_password = var[6:]

    @property
    def is_superuser(self):
        print('TODO calculate is_superuser from roles')
        return True

    @property
    def is_active(self):
        print('TODO calculate is_superuser from roles')
        return True   

    @property
    def is_staff(self):
        return True 

관리자 페이지용 폼을 만듭니다. 아주 간단한 버전입니다. (forms.py)

from django import forms
from django.contrib.auth.forms import ReadOnlyPasswordHashField

from .models import User


class UserCreationForm(forms.ModelForm):
    password = forms.CharField(label='Password', widget=forms.PasswordInput)

    class Meta:
        model = User
        fields = ('email', )

    def save(self, commit=True):
        user = super().save(commit=False)
        user.set_password(self.cleaned_data["password"])
        if commit:
            user.save()
        return user


class UserChangeForm(forms.ModelForm):
    password = ReadOnlyPasswordHashField()

    class Meta:
        model = User
        fields = ('email', 'password')

    def clean_password(self):
        return self.initial["password"]

Admin 클래스를 만듭니다. (admin.py)

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .forms import UserChangeForm, UserCreationForm
from .models import User


class UserAdmin(BaseUserAdmin):
    form = UserChangeForm
    add_form = UserCreationForm
    list_display = ('email', 'name', )
    list_filter = ('roles',)
    search_fields = ('email',)
    ordering = ('email',)
    fieldsets = (
        (None, {'fields': ('email', 'password')}),
    )

admin.site.register(User, UserAdmin)

패스워드 해시를 읽고 쓰는 Hasher 클래스를 BCryptSHA256PasswordHasher를 기반으로 만들었습니다.

import hashlib
from django.contrib.auth.hashers import BasePasswordHasher
from django.utils.crypto import constant_time_compare
        
        
class HERAPasswordHasher(BasePasswordHasher):
    algorithm = "devise"
    digest = hashlib.sha256
    library = ("bcrypt", "bcrypt")
    rounds = 10                                     # devise 가 2a 버전임

    def salt(self):
        bcrypt = self._load_library()
        return bcrypt.gensalt(self.rounds, b'2a')   # devise 가 2a 버전임
        
    def encode(self, password, salt):
        bcrypt = self._load_library()
        password = password.encode()
        data = bcrypt.hashpw(password, salt)
        return "%s%s" % (self.algorithm, data.decode('ascii'))

    def decode(self, encoded):
        algorithm, algostr, work_factor, data = encoded.split('$', 4)
        assert algorithm == self.algorithm
        return {
            'algorithm': algorithm,
            'algostr': algostr,
            'checksum': data[22:],
            'salt': data[:22],
            'work_factor': int(work_factor),
        }

    def verify(self, password, encoded):
        algorithm, data = encoded.split('$', 1)
        assert algorithm == self.algorithm
        encoded_2 = self.encode(password, b'$'+data.encode('ascii')[0:28])   # devise는 솔트로 29글자만 써요.
        return constant_time_compare(encoded, encoded_2)
```

이제 settings 에 다음을 추가합니다. (저는 ruser 라는 이름으로 앱을 만들고 그안에 위의 파일들을 넣었습니다.)

AUTH_USER_MODEL = 'ruser.User'
PASSWORD_HASHERS = [
    'ruser.hashers.HERAPasswordHasher',
]

테스트

테스트를 위해서 레일즈 콘솔과 (bundle exec rails console), 장고 콘솔 (python manager.py shell) 에서 사용자를 다루는 명령들은 다음과 같습니다.

# 레일즈에서 사용자 생성  
User.create!({email: "YOUR_EMAIL", password: "YOUR_PASSWORD", 
              password_confirmation: "YOUR_PASSWORD", name: "YOUR_NAME"}) 

# 레일즈에서 로그인
ApplicationController.allow_forgery_protection = false
app.post('/users/login', {params: {"user"=>{"email"=>"YOUR_EMAIL", "password"=>"YOUR_PASSWORD"}}})
# 장고에서 사용자 로그인
from django.contrib.auth import authenticate
user = authenticate(username='YOUR_EMAIL', password='YOUR_PASSWORD')

# 장고에서 사용자 생성
from herauser.models import User
User.objects.create_user('YOUR_EMAIL', name='YOUR_NAME', password='YOUR_PASSWORD')

# 장고에서 사용자 패스워드 업데이트
from herauser.models import User
u = User.objects.filter(email='YOUR_EMAIL')[0]
u.set_password('YOUR_PASSWORD')
u.save()

양쪽 콘솔에서 해보시면 서로 데이터베이스를 공유하면서 로그인과 패스워드 업데이트가 잘 되는것을 보실 수 있습니다.

english translation is http://tech.jinto.pe.kr/post/2021-05-23-rails-share-database-with-django/